WIP: feat: route modals everywhere #2735

Closed
dpschen wants to merge 15 commits from dpschen/frontend:feature/route-modals-everywhere into main
35 changed files with 653 additions and 209 deletions

View File

@ -19,7 +19,7 @@ describe('Team', () => {
.contains('Create a new team')
cy.get('input.input')
.type(newTeamName)
cy.get('.button')
cy.get('.new-team button')
.contains('Create')
.click()

View File

@ -31,6 +31,7 @@
"@types/lodash.clonedeep": "4.5.7",
"@types/sortablejs": "1.15.0",
"@vueuse/core": "9.6.0",
"@vueuse/integrations": "9.6.0",
"@vueuse/router": "9.6.0",
"axios": "0.27.2",
"blurhash": "2.0.4",
@ -45,6 +46,7 @@
"flatpickr": "4.6.13",
"flexsearch": "0.7.21",
"floating-vue": "2.0.0-beta.20",
"focus-trap": "^7.1.0",
"highlight.js": "11.6.0",
"is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0",

View File

@ -33,6 +33,7 @@ specifiers:
'@vue/test-utils': 2.2.4
'@vue/tsconfig': 0.1.3
'@vueuse/core': 9.6.0
'@vueuse/integrations': 9.6.0
'@vueuse/router': 9.6.0
autoprefixer: 10.4.13
axios: 0.27.2
@ -56,6 +57,7 @@ specifiers:
flatpickr: 4.6.13
flexsearch: 0.7.21
floating-vue: 2.0.0-beta.20
focus-trap: ^7.1.0
happy-dom: 7.7.0
highlight.js: 11.6.0
is-touch-device: 1.0.1
@ -104,6 +106,7 @@ dependencies:
'@types/lodash.clonedeep': 4.5.7
'@types/sortablejs': 1.15.0
'@vueuse/core': 9.6.0_vue@3.2.45
'@vueuse/integrations': 9.6.0_v6so3rexzmt44vm4rsvyznwkka
'@vueuse/router': 9.6.0_xsxatmlnmmg5bcuv3xdnj6fj7y
axios: 0.27.2
blurhash: 2.0.4
@ -118,6 +121,7 @@ dependencies:
flatpickr: 4.6.13
flexsearch: 0.7.21
floating-vue: 2.0.0-beta.20_vue@3.2.45
focus-trap: 7.1.0
highlight.js: 11.6.0
is-touch-device: 1.0.1
lodash.clonedeep: 4.5.0
@ -3236,13 +3240,18 @@ packages:
- supports-color
dev: true
/@typescript-eslint/types/5.43.0:
resolution: {integrity: sha512-jpsbcD0x6AUvV7tyOlyvon0aUsQpF8W+7TpJntfCUWU1qaIKu2K34pMwQKSzQH8ORgUrGYY6pVIh1Pi8TNeteg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@typescript-eslint/types/5.44.0:
resolution: {integrity: sha512-Tp+zDnHmGk4qKR1l+Y1rBvpjpm5tGXX339eAlRBDg+kgZkz9Bw+pqi4dyseOZMsGuSH69fYfPJCBKBrbPCxYFQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@typescript-eslint/typescript-estree/5.44.0_mmt6grxdx77rvjuvebzbfquz6y:
resolution: {integrity: sha512-M6Jr+RM7M5zeRj2maSfsZK2660HKAJawv4Ud0xT+yauyvgrsHu276VtXlKDFnEmhG+nVEd0fYZNXGoAgxwDWJw==}
/@typescript-eslint/typescript-estree/5.43.0_mmt6grxdx77rvjuvebzbfquz6y:
resolution: {integrity: sha512-BZ1WVe+QQ+igWal2tDbNg1j2HWUkAa+CVqdU79L4HP9izQY6CNhXfkNwd1SS4+sSZAP/EthI1uiCSY/+H0pROg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
typescript: '*'
@ -3250,8 +3259,8 @@ packages:
typescript:
optional: true
dependencies:
'@typescript-eslint/types': 5.44.0
'@typescript-eslint/visitor-keys': 5.44.0
'@typescript-eslint/types': 5.43.0
'@typescript-eslint/visitor-keys': 5.43.0
debug: 4.3.4_supports-color@9.2.1
globby: 11.1.0
is-glob: 4.0.3
@ -3262,6 +3271,27 @@ packages:
- supports-color
dev: true
/@typescript-eslint/typescript-estree/5.43.0_typescript@4.9.3:
resolution: {integrity: sha512-BZ1WVe+QQ+igWal2tDbNg1j2HWUkAa+CVqdU79L4HP9izQY6CNhXfkNwd1SS4+sSZAP/EthI1uiCSY/+H0pROg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/types': 5.43.0
'@typescript-eslint/visitor-keys': 5.43.0
debug: 4.3.4
globby: 11.1.0
is-glob: 4.0.3
semver: 7.3.7
tsutils: 3.21.0_typescript@4.9.3
typescript: 4.9.3
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/typescript-estree/5.44.0_typescript@4.9.3:
resolution: {integrity: sha512-M6Jr+RM7M5zeRj2maSfsZK2660HKAJawv4Ud0xT+yauyvgrsHu276VtXlKDFnEmhG+nVEd0fYZNXGoAgxwDWJw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -3303,6 +3333,14 @@ packages:
- typescript
dev: true
/@typescript-eslint/visitor-keys/5.43.0:
resolution: {integrity: sha512-icl1jNH/d18OVHLfcwdL3bWUKsBeIiKYTGxMJCoGe7xFht+E4QgzOqoWYrU8XSLJWhVw8nTacbm03v23J/hFTg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
'@typescript-eslint/types': 5.43.0
eslint-visitor-keys: 3.3.0
dev: true
/@typescript-eslint/visitor-keys/5.44.0:
resolution: {integrity: sha512-a48tLG8/4m62gPFbJ27FxwCOqPKxsb8KC3HkmYoq2As/4YyjQl1jDbRr1s63+g4FS/iIehjmN3L5UjmKva1HzQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -3661,6 +3699,54 @@ packages:
- vue
dev: false
/@vueuse/integrations/9.6.0_v6so3rexzmt44vm4rsvyznwkka:
resolution: {integrity: sha512-+rs2OWY/3spxoAGQMnlHQpxf8ErAYf4D1bT0aXaPnxphmtYgexm6KIjTFpBbcQnHwVi1g2ET1SJoQL16yDrgWA==}
peerDependencies:
async-validator: '*'
axios: '*'
change-case: '*'
drauu: '*'
focus-trap: '*'
fuse.js: '*'
idb-keyval: '*'
jwt-decode: '*'
nprogress: '*'
qrcode: '*'
universal-cookie: '*'
peerDependenciesMeta:
async-validator:
optional: true
axios:
optional: true
change-case:
optional: true
drauu:
optional: true
focus-trap:
optional: true
fuse.js:
optional: true
idb-keyval:
optional: true
jwt-decode:
optional: true
nprogress:
optional: true
qrcode:
optional: true
universal-cookie:
optional: true
dependencies:
'@vueuse/core': 9.6.0_vue@3.2.45
'@vueuse/shared': 9.6.0_vue@3.2.45
axios: 0.27.2
focus-trap: 7.1.0
vue-demi: 0.12.1_vue@3.2.45
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: false
/@vueuse/metadata/9.6.0:
resolution: {integrity: sha512-sIC8R+kWkIdpi5X2z2Gk8TRYzmczDwHRhEFfCu2P+XW2JdPoXrziqsGpDDsN7ykBx4ilwieS7JUIweVGhvZ93w==}
dev: false
@ -5775,7 +5861,7 @@ packages:
resolution: {integrity: sha512-lR78AugfUSBojwlSRZBeEqQ1l8LI7rbxOl1qTUnGLcjZQDjZmrZCb7R46rK8U8B5WzFvJrxa7fEBA8FoD/n5fA==}
engines: {node: ^12.20.0 || ^14.14.0 || >=16.0.0}
dependencies:
'@typescript-eslint/typescript-estree': 5.44.0_typescript@4.9.3
'@typescript-eslint/typescript-estree': 5.43.0_typescript@4.9.3
ast-module-types: 3.0.0
node-source-walk: 5.0.0
typescript: 4.9.3
@ -5787,7 +5873,7 @@ packages:
resolution: {integrity: sha512-lR78AugfUSBojwlSRZBeEqQ1l8LI7rbxOl1qTUnGLcjZQDjZmrZCb7R46rK8U8B5WzFvJrxa7fEBA8FoD/n5fA==}
engines: {node: ^12.20.0 || ^14.14.0 || >=16.0.0}
dependencies:
'@typescript-eslint/typescript-estree': 5.44.0_mmt6grxdx77rvjuvebzbfquz6y
'@typescript-eslint/typescript-estree': 5.43.0_mmt6grxdx77rvjuvebzbfquz6y
ast-module-types: 3.0.0
node-source-walk: 5.0.0
typescript: 4.9.3
@ -7008,6 +7094,12 @@ packages:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
dev: true
/focus-trap/7.1.0:
resolution: {integrity: sha512-CuJvwUBfJCWcU6fc4xr3UwMF5vWnox4isXAixCwrPzCsPKOQjP9T+nTlYT2t+vOmQL8MOQ16eim99XhjQHAuiQ==}
dependencies:
tabbable: 6.0.1
dev: false
/folder-walker/3.2.0:
resolution: {integrity: sha512-VjAQdSLsl6AkpZNyrQJfO7BXLo4chnStqb055bumZMbRUPpVuPN3a4ktsnRCmrFZjtMlYLkyXiR5rAs4WOpC4Q==}
dependencies:
@ -12255,6 +12347,10 @@ packages:
resolution: {integrity: sha512-P3cgh2bpaPvAO2NE3uRp/n6hmk4xPX4DQf+UzTlCAycssKdqhp6hjw+ENWe+aUS7TogKRFtptMosTSFeC6R55g==}
dev: true
/tabbable/6.0.1:
resolution: {integrity: sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA==}
dev: false
/tabtab/3.0.2:
resolution: {integrity: sha512-jANKmUe0sIQc/zTALTBy186PoM/k6aPrh3A7p6AaAfF6WPSbTx1JYeGIGH162btpH+mmVEXln+UxwViZHO2Jhg==}
dependencies:
@ -12968,7 +13064,7 @@ packages:
dev: true
/verror/1.10.0:
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
resolution: {integrity: sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=}
engines: {'0': node >=0.6.0}
dependencies:
assert-plus: 1.0.0

View File

@ -0,0 +1,27 @@
<script lang="ts">
export default { inheritAttrs: false }
</script>
<script setup lang="ts">
/**
* This component makes it possible to optionally wrap around another component.
* It's based on these ideas:
*
* - https://github.com/vuejs/rfcs/pull/449
* - https://github.com/vuejs/rfcs/discussions/448#discussioncomment-2769396=
*/
defineProps<{
/**
* If the `is` prop defines a component it will be rendered as a wrapper around the slot content.
* If the `is` prop is undefined there won't be any wrapper.
*/
is: any,
}>()
</script>
<template>
<component v-if="is" :is="is" v-bind="$attrs">
<slot />
</component>
<slot v-else />
</template>

View File

@ -16,7 +16,7 @@
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
</h1>
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="info-button">
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id},}" class="info-button">
<icon icon="circle-info"/>
</BaseButton>
@ -75,7 +75,7 @@
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
<dropdown-item
:to="{name: 'about'}"
:to="{name: 'about',}"
>
{{ $t('about.title') }}
</dropdown-item>

View File

@ -32,20 +32,13 @@
<quick-actions/>
<router-view :route="routeWithModal" v-slot="{ Component }">
<router-view :route="baseRoute" v-slot="{ Component }">
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component"/>
</keep-alive>
</router-view>
<modal
:enabled="Boolean(currentModal)"
@close="closeModal()"
variant="scrolling"
class="task-detail-view-modal"
>
<component :is="currentModal"/>
</modal>
<component :is="modalRoute" />
<BaseButton
class="keyboard-shortcuts-button d-print-none"
@ -73,7 +66,7 @@ import {useLabelStore} from '@/stores/labels'
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
const {baseRoute, modalRoute} = useRouteWithModal()
const baseStore = useBaseStore()
const background = computed(() => baseStore.background)

View File

@ -367,6 +367,7 @@ watch(
left: 0;
z-index: 7;
overflow: auto;
-webkit-overflow-scrolling: touch;
display: none;
box-sizing: border-box;
}
@ -379,6 +380,7 @@ watch(
right: 0;
z-index: 9;
overflow: auto;
-webkit-overflow-scrolling: touch;
display: none;
box-sizing: border-box;
border: 1px solid #ddd;

View File

@ -20,6 +20,7 @@ import {
faEllipsisH,
faEllipsisV,
faExclamation,
faExpand,
faEye,
faEyeSlash,
faFillDrip,
@ -95,6 +96,7 @@ library.add(faCog)
library.add(faComments)
library.add(faEllipsisH)
library.add(faEllipsisV)
library.add(faExpand)
library.add(faExclamation)
library.add(faEye)
library.add(faEyeSlash)

View File

@ -1,38 +1,38 @@
<template>
<modal @close="$router.back()" :overflow="true" :wide="wide">
<modal :overflow="true" :wide="wide" #default="{ onClose }">
<card
:title="title"
:shadow="false"
:padding="false"
class="has-text-left"
:has-close="true"
@close="$router.back()"
@close="onClose"
:loading="loading"
>
<div class="p-4">
<slot/>
<slot :onClose="onClose" />
</div>
<template #footer>
<slot name="footer">
<slot name="footer" :onClose="onClose">
<x-button
v-if="tertiary !== ''"
:shadow="false"
variant="tertiary"
@click.prevent.stop="$emit('tertiary')"
@click="$emit('tertiary')"
>
{{ tertiary }}
</x-button>
<x-button
variant="secondary"
@click.prevent.stop="$router.back()"
@click="onClose"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
v-if="hasPrimaryAction"
variant="primary"
@click.prevent.stop="primary()"
@click="primary(onClose)"
:icon="primaryIcon"
:disabled="primaryDisabled || loading"
class="ml-2"
@ -83,10 +83,11 @@ defineProps({
},
})
// 'close'
const emit = defineEmits(['create', 'primary', 'tertiary'])
function primary() {
emit('create')
emit('primary')
function primary(onClose: () => void) {
emit('create', onClose)
emit('primary', onClose)
}
</script>

View File

@ -1,7 +1,7 @@
<template>
<Teleport to="body">
<!-- FIXME: transition should not be included in the modal -->
<CustomTransition :name="transitionName" appear>
<CustomTransition :name="transitionName" appear :appear-class="transitionName">
<section
v-if="enabled"
class="modal-mask"
@ -10,11 +10,11 @@
variant,
]"
ref="modal"
v-bind="attrs"
v-bind="$attrs"
>
<div
class="modal-container"
@click.self.prevent.stop="$emit('close')"
@click.self.prevent.stop="onClose"
v-shortcut="'Escape'"
>
<div
@ -25,13 +25,13 @@
}"
>
<BaseButton
@click="$emit('close')"
@click="onClose"
class="close"
>
<icon icon="times"/>
</BaseButton>
<slot>
<slot name="default" :onClose="onClose">
<div class="header">
<slot name="header"></slot>
</div>
@ -40,14 +40,14 @@
</div>
<div class="actions">
<x-button
@click="$emit('close')"
@click="onClose"
variant="tertiary"
class="has-text-danger"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
@click="$emit('submit')"
@click="$emit('submit', onClose)"
variant="primary"
v-cy="'modalPrimary'"
:shadow="false"
@ -70,10 +70,13 @@ export default {
</script>
<script lang="ts" setup>
import {nextTick, ref, watchEffect} from 'vue'
import {useScrollLock} from '@vueuse/core'
import {useFocusTrap} from '@vueuse/integrations/useFocusTrap'
import type {RouteLocationRaw} from 'vue-router'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {ref, useAttrs, watchEffect} from 'vue'
import {useScrollLock} from '@vueuse/core'
const props = withDefaults(defineProps<{
enabled?: boolean,
@ -81,22 +84,34 @@ const props = withDefaults(defineProps<{
wide?: boolean,
transitionName?: 'modal' | 'fade',
variant?: 'default' | 'hint-modal' | 'scrolling',
closeRoute?: RouteLocationRaw,
}>(), {
enabled: true,
transitionName: 'modal',
variant: 'default',
})
defineEmits(['close', 'submit'])
const attrs = useAttrs()
const modal = ref<HTMLElement | null>(null)
const scrollLock = useScrollLock(modal)
const emit = defineEmits(['close', 'submit'])
const scrollLock = useScrollLock(document.body)
watchEffect(() => {
scrollLock.value = props.enabled
})
const modal = ref(null)
const {activate, deactivate} = useFocusTrap(modal)
watchEffect(() => {
if (props.enabled) {
// wait for content to be loaded
nextTick(() => activate())
} else {
deactivate()
}
})
function onClose() {
emit('close')
}
</script>
<style lang="scss" scoped>
@ -122,6 +137,8 @@ $modal-width: 1024px;
height: 100%;
max-height: 100vh;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.default .modal-content,
@ -131,6 +148,7 @@ $modal-width: 1024px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
overflow: visible; // reset bulma
@media screen and (max-width: $tablet) {
margin: 0;

View File

@ -153,7 +153,6 @@ function openTask(e: {
router.push({
name: 'task.detail',
params: {id: e.bar.ganttBarConfig.id},
state: {backdropView: router.currentRoute.value.fullPath},
})
}

View File

@ -121,7 +121,6 @@ function openTaskDetail() {
router.push({
name: 'task.detail',
params: {id: props.task.id},
state: {backdropView: router.currentRoute.value.fullPath},
})
}

View File

@ -13,7 +13,7 @@
/>
<router-link
:to="taskDetailRoute"
:to="{name: 'task.detail', params: {id: task.id}}"
:class="{ 'done': task.done}"
class="tasktext"
>
@ -221,14 +221,6 @@ const currentList = computed(() => {
} : baseStore.currentList
})
const taskDetailRoute = computed(() => ({
name: 'task.detail',
params: {id: task.value.id},
// TODO: re-enable opening task detail in modal
// state: { backdropView: router.currentRoute.value.fullPath },
}))
async function markAsDone(checked: boolean) {
const updateFunc = async () => {
const newTask = await taskStore.update(task.value)

View File

@ -1,56 +1,200 @@
import { computed, shallowRef, watchEffect, h, type VNode } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {computed, shallowRef, watch, h, type VNode, ref} from 'vue'
import {useRoute, useRouter, loadRouteLocation, type RouteLocationNormalizedLoaded, type RouteLocationRaw, START_LOCATION} from 'vue-router'
import router, { handleRedirectRecord } from '@/router'
// this is adapted from vue-router
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
function getRouteProps(route: RouteLocationNormalizedLoaded) {
const routePropsOption = route.matched[0]?.props.default
return routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: {}
}
function resolveAndLoadRoute(route: RouteLocationRaw, currentLocation?: RouteLocationNormalizedLoaded) {
const resolvedRoute = router.resolve(route, currentLocation)
// resolvedRoute.matched.forEach((record) => console.log(record.redirect))
// e.g. 'list.index' will always redirect, so we need to resolve the redirected route record
const redirectedRoute = handleRedirectRecord(resolvedRoute) || resolvedRoute
return loadRouteLocation(router.resolve(redirectedRoute, currentLocation))
}
export function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
const routeWithModal = computed(() => {
return backdropView.value
? router.resolve(backdropView.value)
: route
const historyStateBackdropRoutePath = computed<RouteLocationRaw | undefined>(() => {
// every time the fullPath changes we check the history state
// this happens also initially
return route.fullPath
? history.state?.backdropRoutePath
: undefined
})
const currentModal = shallowRef<VNode>()
watchEffect(() => {
if (!backdropView.value) {
currentModal.value = undefined
const isInitialNavigation = ref(true)
router.beforeEach((to, from) => {
isInitialNavigation.value = from === START_LOCATION
})
const lastBackdropRoutePath = ref<string>()
router.afterEach((to, from) => {
const resolvedRoute = router.resolve(to)
if (!resolvedRoute.meta.modal) {
// this route doesn't define that it can be a modal
return
}
// this is adapted from vue-router
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
const routePropsOption = route.matched[0]?.props.default
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: {}
let backdropRoutePath
if (resolvedRoute.meta?.modal === true || resolvedRoute.meta.modal?.force !== true) {
// this route can be shown as modal or as normal view
routeProps.backdropView = backdropView.value
// TODO: add new state or prop instead of checking backdropRoutePath here
// e.g. `modal` with `'normal' | 'sidebar' | 'modal' | 'sheet'`
if (!history.state.backdropRoutePath) {
// since this modal is optional and we don't have a backdropRoutePath we don't do anything
return
}
} else if (lastBackdropRoutePath.value) {
// there was already a modal in the last route
// we have to save this in the lastBackdropRoutePath ref because
// the state gets overwritten
const component = route.matched[0]?.components?.default
if (!component) {
currentModal.value = undefined
return
// let's show that backdropRoute again
// TODO: add support for multiple modal layers here
// FIXME: use `history.state.backdropRoutePath` from last route
// FIXME: this might be wrong, because we redirect to our current path forever??
backdropRoutePath = lastBackdropRoutePath.value
} else if (isInitialNavigation.value === false) {
backdropRoutePath = from.fullPath
} else if (resolvedRoute.meta.modal?.defaultBackdropRoute) {
const {defaultBackdropRoute} = resolvedRoute.meta.modal
const routeRaw = typeof defaultBackdropRoute === 'function'
? defaultBackdropRoute(resolvedRoute)
: defaultBackdropRoute
backdropRoutePath = router.resolve(routeRaw).fullPath
}
currentModal.value = h(component, routeProps)
if (backdropRoutePath === undefined) {
console.log('No defaultBackdropRoute defined for this route')
// TODO: maybe load parent route here in the future,
// via: route.matched[route.matched.length - 2]
// see: https://router.vuejs.org/guide/migration/#removal-of-parent-from-route-locations
backdropRoutePath = router.resolve({ name: 'home' }).fullPath
}
lastBackdropRoutePath.value = backdropRoutePath
history.replaceState({
...history.state,
backdropRoutePath,
}, '')
})
const routerIsReady = ref(false)
router.isReady().then(() => {
routerIsReady.value = true
})
function closeModal() {
const historyState = computed(() => route.fullPath && window.history.state)
const hasModal = ref(false)
const baseRoute = shallowRef<RouteLocationNormalizedLoaded>()
watch(
[routerIsReady, historyStateBackdropRoutePath],
async () => {
if (routerIsReady.value === false || !route.fullPath) {
// wait until we can work with routes
hasModal.value = false
return
}
if (historyState.value) {
router.back()
if (historyStateBackdropRoutePath.value === undefined) {
if (typeof route.meta?.modal === 'boolean' || route.meta.modal?.force !== true) {
hasModal.value = false
baseRoute.value = route
return
}
// the route forces to be shown as a modal
hasModal.value = true
let routeRaw: RouteLocationRaw
if (route.meta.modal?.defaultBackdropRoute) {
const {defaultBackdropRoute} = route.meta.modal
routeRaw = typeof defaultBackdropRoute === 'function'
? defaultBackdropRoute(route)
: defaultBackdropRoute
} else {
// TODO: maybe load parent route here in the future,
// via: route.matched[route.matched.length - 2]
// see: https://router.vuejs.org/guide/migration/#removal-of-parent-from-route-locations
routeRaw = { name: 'home' }
}
baseRoute.value = await resolveAndLoadRoute(routeRaw, baseRoute.value)
return
}
// we get the resolved route from the fullpath
// and wait for the route component to be loaded before we assign it
hasModal.value = true
baseRoute.value = await resolveAndLoadRoute(historyStateBackdropRoutePath.value, baseRoute.value)
},
{immediate: true},
)
const backdropRoute = computed(() => route.fullPath !== baseRoute.value?.fullPath ? baseRoute.value : undefined)
const modalRoute = shallowRef<VNode>()
watch(
() => [hasModal.value, backdropRoute.value, route],
() => {
if (hasModal.value === false) {
modalRoute.value = undefined
return
}
const props = getRouteProps(route)
props.isModal = true
props.backdropRoutePath = backdropRoute.value?.fullPath
props.onClose = closeModal
const component = route.matched[0]?.components?.default
if (!component) {
modalRoute.value = undefined
return
}
modalRoute.value = h(component, props)
},
{immediate: true},
)
async function closeModal() {
await router.isReady()
if (isInitialNavigation.value === false) {
return router.back()
} else if (backdropRoute.value !== undefined) {
// TODO: Dialog modals might want to replace the route here via router.replace()
return router.push(backdropRoute.value)
}
if (history.state === undefined) {
return router.push({ name: 'home' })
} else {
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
router.push(backdropRoute)
router.back()
// this should never happen
throw new Error('')
}
}
return {routeWithModal, currentModal, closeModal}
return {
baseRoute,
modalRoute,
}
}

View File

@ -1,5 +1,4 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteLocation } from 'vue-router'
import {createRouter, createWebHistory, type RouteLocation, type RouteLocationRaw} from 'vue-router'
import {saveLastVisited} from '@/helpers/saveLastVisited'
import {saveListView, getListView} from '@/helpers/saveListView'
@ -80,6 +79,19 @@ const NewNamespaceComponent = () => import('@/views/namespaces/NewNamespace.vue'
const EditTeamComponent = () => import('@/views/teams/EditTeam.vue')
const NewTeamComponent = () => import('@/views/teams/NewTeam.vue')
declare module 'vue-router' {
interface RouteMeta {
title?: string
modal?: boolean | {
/** Forces the view to be always shown as modal */
force?: boolean,
/** Show this route as backdrop if there is no route history */
defaultBackdropRoute?: RouteLocationRaw | ((to: RouteLocationNormalized) => RouteLocationRaw),
}
}
}
const router = createRouter({
history: createWebHistory(),
scrollBehavior(to, from, savedPosition) {
@ -213,7 +225,10 @@ const router = createRouter({
name: 'namespace.create',
component: NewNamespaceComponent,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute: {name: 'namespaces.index'},
},
},
},
{
@ -221,7 +236,10 @@ const router = createRouter({
name: 'namespace.settings.edit',
component: NamespaceSettingEdit,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute: {name: 'namespaces.index'},
},
},
props: route => ({ namespaceId: Number(route.params.id as string) }),
},
@ -230,7 +248,10 @@ const router = createRouter({
name: 'namespace.settings.share',
component: NamespaceSettingShare,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute: {name: 'namespaces.index'},
},
},
},
{
@ -238,16 +259,22 @@ const router = createRouter({
name: 'namespace.settings.archive',
component: NamespaceSettingArchive,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute: {name: 'namespaces.index'},
},
},
props: route => ({ namespaceId: parseInt(route.params.id as string) }),
props: route => ({ namespaceId: Number(route.params.id as string) }),
},
{
path: '/namespaces/:id/settings/delete',
name: 'namespace.settings.delete',
component: NamespaceSettingDelete,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute: {name: 'namespaces.index'},
},
},
props: route => ({ namespaceId: Number(route.params.id as string) }),
},
@ -256,6 +283,14 @@ const router = createRouter({
name: 'task.detail',
component: TaskDetailView,
props: route => ({ taskId: Number(route.params.id as string) }),
meta: {
modal: {
force: true,
defaultBackdropRoute(route) {
return { name: 'list.index', props: { listId: route.params.listId}}
},
},
},
},
{
path: '/tasks/by/upcoming',
@ -273,7 +308,10 @@ const router = createRouter({
name: 'list.create',
component: NewListComponent,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute: {name: 'namespaces.index'},
},
},
},
{
@ -282,7 +320,12 @@ const router = createRouter({
component: ListSettingEdit,
props: route => ({ listId: Number(route.params.listId as string) }),
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute(route) {
return { name: 'list.index', props: { listId: route.params.listId}}
},
},
},
},
{
@ -290,7 +333,12 @@ const router = createRouter({
name: 'list.settings.background',
component: ListSettingBackground,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute(route) {
return { name: 'list.index', props: { listId: route.params.listId}}
},
},
},
},
{
@ -298,7 +346,12 @@ const router = createRouter({
name: 'list.settings.duplicate',
component: ListSettingDuplicate,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute(route) {
return { name: 'list.index', props: { listId: route.params.listId}}
},
},
},
},
{
@ -306,7 +359,12 @@ const router = createRouter({
name: 'list.settings.share',
component: ListSettingShare,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute(route) {
return { name: 'list.index', props: { listId: route.params.listId}}
},
},
},
},
{
@ -314,7 +372,12 @@ const router = createRouter({
name: 'list.settings.delete',
component: ListSettingDelete,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute(route) {
return { name: 'list.index', props: { listId: route.params.listId}}
},
},
},
},
{
@ -322,7 +385,12 @@ const router = createRouter({
name: 'list.settings.archive',
component: ListSettingArchive,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute(route) {
return { name: 'list.index', props: { listId: route.params.listId}}
},
},
},
},
{
@ -330,7 +398,12 @@ const router = createRouter({
name: 'filter.settings.edit',
component: FilterEdit,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute(route) {
return { name: 'list.index', props: { listId: route.params.listId}}
},
},
},
props: route => ({ listId: Number(route.params.listId as string) }),
},
@ -339,7 +412,12 @@ const router = createRouter({
name: 'filter.settings.delete',
component: FilterDelete,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute(route) {
return { name: 'list.index', props: { listId: route.params.listId}}
},
},
},
props: route => ({ listId: Number(route.params.listId as string) }),
},
@ -348,7 +426,12 @@ const router = createRouter({
name: 'list.info',
component: ListInfo,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute(route) {
return { name: 'list.index', props: { listId: route.params.listId}}
},
},
},
props: route => ({ listId: Number(route.params.listId as string) }),
},
@ -358,8 +441,7 @@ const router = createRouter({
redirect(to) {
// Redirect the user to list view by default
const savedListView = getListView(to.params.listId)
console.debug('Replaced list view with', savedListView)
const savedListView = getListView(Number(to.params.listId))
return {
name: router.hasRoute(savedListView)
@ -373,14 +455,14 @@ const router = createRouter({
path: '/lists/:listId/list',
name: 'list.list',
component: ListList,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
beforeEnter: (to) => saveListView(Number(to.params.listId), to.name as string),
props: route => ({ listId: Number(route.params.listId as string) }),
},
{
path: '/lists/:listId/gantt',
name: 'list.gantt',
component: ListGantt,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
beforeEnter: (to) => saveListView(Number(to.params.listId), to.name as string),
// FIXME: test if `useRoute` would be the same. If it would use it instead.
props: route => ({route}),
},
@ -388,7 +470,7 @@ const router = createRouter({
path: '/lists/:listId/table',
name: 'list.table',
component: ListTable,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
beforeEnter: (to) => saveListView(Number(to.params.listId), to.name as string),
props: route => ({ listId: Number(route.params.listId as string) }),
},
{
@ -396,7 +478,7 @@ const router = createRouter({
name: 'list.kanban',
component: ListKanban,
beforeEnter: (to) => {
saveListView(to.params.listId, to.name)
saveListView(Number(to.params.listId), to.name as string)
// Properly set the page title when a task popup is closed
const listStore = useListStore()
const listFromStore = listStore.getListById(Number(to.params.listId))
@ -416,9 +498,13 @@ const router = createRouter({
name: 'teams.create',
component: NewTeamComponent,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute: {name: 'teams.index'},
},
},
},
// TODO: make modal
{
path: '/teams/:id/edit',
name: 'teams.edit',
@ -434,7 +520,10 @@ const router = createRouter({
name: 'labels.create',
component: NewLabelComponent,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute: {name: 'labels.index'},
},
},
},
{
@ -456,7 +545,10 @@ const router = createRouter({
name: 'filters.create',
component: FilterNew,
meta: {
showAsModal: true,
modal: {
force: true,
defaultBackdropRoute: {name: 'namespaces.index'},
},
},
},
{
@ -468,6 +560,11 @@ const router = createRouter({
path: '/about',
name: 'about',
component: About,
meta: {
modal: {
force: true,
},
},
},
],
})
@ -516,4 +613,59 @@ router.beforeEach(async (to) => {
return getAuthForRoute(to)
})
// https://github.com/vuejs/router/blob/4386ec992f2b96e7309c88f5174f667d9a8a26b2/packages/router/src/router.ts#L595-L628
export function handleRedirectRecord(to: RouteLocation): RouteLocationRaw | void {
const lastMatched = to.matched[to.matched.length - 1]
if (!lastMatched || !lastMatched.redirect) {
return
}
const { redirect } = lastMatched
let newTargetLocation = redirect
if (typeof redirect === 'function') {
newTargetLocation = redirect(to)
}
if (typeof newTargetLocation === 'string') {
newTargetLocation =
newTargetLocation.includes('?') || newTargetLocation.includes('#')
// because I didn't want to copy `locationAsObject` as well
// I replaced it with `router.resolve`.
// This might cause problems, but I'm not sure of which kind
// ? (newTargetLocation = locationAsObject(newTargetLocation))
? (newTargetLocation = router.resolve(newTargetLocation))
: // force empty params
{ path: newTargetLocation }
// @ts-expect-error: force empty params when a string is passed to let
// the router parse them again
newTargetLocation.params = {}
}
if (
// __DEV__ &&
!('path' in newTargetLocation) &&
!('name' in newTargetLocation)
) {
console.log(
`Invalid redirect found:\n${JSON.stringify(
newTargetLocation,
null,
2,
)}\n when navigating to "${
to.fullPath
}". A redirect must contain a name or path. This will break in production.`,
)
throw new Error('Invalid redirect')
}
return Object.assign(
{
query: to.query,
hash: to.hash,
// avoid transferring params if the redirect has a path
params: 'path' in newTargetLocation ? {} : to.params,
},
newTargetLocation,
)
}
export default router

View File

@ -113,7 +113,7 @@ export function useSavedFilter(listId?: MaybeRef<IList['id']>) {
router.push({name: 'list.index', params: {listId: getListId(filter.value)}})
}
async function saveFilter() {
async function saveFilter(callback: () => void) {
const response = await filterService.update(filter.value)
await namespaceStore.loadNamespaces()
success({message: t('filters.edit.success')})
@ -123,7 +123,7 @@ export function useSavedFilter(listId?: MaybeRef<IList['id']>) {
id: getListId(filter.value),
title: filter.value.title,
}))
router.back()
callback()
}
async function deleteFilter() {

View File

@ -1,14 +1,14 @@
<template>
<modal
@close="$router.back()"
transition-name="fade"
variant="hint-modal"
>
>
<template #default="{onClose}">
<card
class="has-no-shadow"
:title="$t('about.title')"
:has-close="true"
@close="$router.back()"
@close="onClose"
:padding="false"
>
<div class="p-4">
@ -22,12 +22,13 @@
<template #footer>
<x-button
variant="secondary"
@click.prevent.stop="$router.back()"
@click="onClose"
>
{{ $t('misc.close') }}
</x-button>
</template>
</card>
</template>
</modal>
</template>

View File

@ -1,8 +1,5 @@
<template>
<modal
@close="$router.back()"
@submit="deleteFilter()"
>
<modal @submit="deleteFilter()">
<template #header>
<span>{{ $t('filters.delete.header') }}</span>
</template>

View File

@ -6,15 +6,16 @@
@primary="saveFilter"
:tertiary="$t('misc.delete')"
@tertiary="$router.push({ name: 'filter.settings.delete', params: { id: listId } })"
#default="{onClose}"
>
<form @submit.prevent="saveFilter()">
<form @submit.prevent="saveFilter(onClose)">
<div class="field">
<label class="label" for="title">{{ $t('filters.attributes.title') }}</label>
<div class="control">
<input
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading || undefined"
@keyup.enter="saveFilter"
@keyup.enter="saveFilter(onClose)"
class="input"
id="title"
:placeholder="$t('filters.attributes.titlePlaceholder')"

View File

@ -1,8 +1,5 @@
<template>
<modal
@close="$router.back()"
variant="hint-modal"
>
<modal variant="hint-modal">
<card class="has-no-shadow" :title="$t('filters.create.title')">
<p>
{{ $t('filters.create.description') }}

View File

@ -1,7 +1,7 @@
<template>
<div :class="{ 'is-loading': loading}" class="loader-container">
<x-button
:to="{name:'labels.create'}"
:to="{ name:'labels.create' }"
class="is-pulled-right"
icon="plus"
>

View File

@ -1,10 +1,6 @@
<template>
<modal
@close="$router.back()"
>
<card
:title="list.title"
>
<modal>
<card :title="list.title">
<div class="has-text-left" v-html="htmlDescription" v-if="htmlDescription !== ''"></div>
<p v-else class="is-italic">
{{ $t('list.noDescriptionAvailable') }}

View File

@ -181,7 +181,6 @@
<script setup lang="ts">
import {toRef, computed, type Ref} from 'vue'
import {useStorage} from '@vueuse/core'
import ListWrapper from '@/components/list/ListWrapper.vue'
@ -274,15 +273,12 @@ function sort(property: keyof SortBy) {
sortByParam.value = sortBy.value
}
// TODO: re-enable opening task detail in modal
// const router = useRouter()
const taskDetailRoutes = computed(() => Object.fromEntries(
tasks.value.map(({id}) => ([
id,
{
name: 'task.detail',
params: {id},
// state: { backdropView: router.currentRoute.value.fullPath },
},
])),
))

View File

@ -1,5 +1,10 @@
<template>
<create-edit :title="$t('list.create.header')" @create="createNewList()" :primary-disabled="list.title === ''">
<create-edit
:title="$t('list.create.header')"
@create="createNewList()"
:primary-disabled="list.title === ''"
#default="{onClose}"
>
<div class="field">
<label class="label" for="listTitle">{{ $t('list.title') }}</label>
<div
@ -9,7 +14,7 @@
<input
:class="{ disabled: listService.loading }"
@keyup.enter="createNewList()"
@keyup.esc="$router.back()"
@keyup.esc="onClose"
class="input"
:placeholder="$t('list.create.titlePlaceholder')"
type="text"

View File

@ -1,8 +1,5 @@
<template>
<modal
@close="$router.back()"
@submit="archiveList()"
>
<modal @submit="archiveList">
<template #header><span>{{ list.isArchived ? $t('list.archive.unarchive') : $t('list.archive.archive') }}</span></template>
<template #text>
@ -17,7 +14,7 @@ export default {name: 'list-setting-archive'}
<script setup lang="ts">
import {computed} from 'vue'
import {useRouter, useRoute} from 'vue-router'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {success} from '@/message'
@ -28,13 +25,15 @@ import {useListStore} from '@/stores/lists'
const {t} = useI18n({useScope: 'global'})
const listStore = useListStore()
const router = useRouter()
const route = useRoute()
const list = computed(() => listStore.getListById(route.params.listId))
useTitle(() => t('list.archive.title', {list: list.value.title}))
useTitle(() => list.value
? t('list.archive.title', {list: list.value.title})
: undefined,
)
async function archiveList() {
async function archiveList(onClose: () => void) {
try {
const newList = await listStore.updateList({
...list.value,
@ -43,7 +42,7 @@ async function archiveList() {
useBaseStore().setCurrentList(newList)
success({message: t('list.archive.success')})
} finally {
router.back()
onClose()
}
}
</script>

View File

@ -73,19 +73,19 @@
</x-button>
</template>
<template #footer>
<template #footer="{onClose}">
<x-button
v-if="hasBackground"
:shadow="false"
variant="tertiary"
class="is-danger"
@click.prevent.stop="removeBackground"
@click="removeBackground(onClose)"
>
{{ $t('list.background.remove') }}
</x-button>
<x-button
variant="secondary"
@click.prevent.stop="$router.back()"
@click="onClose"
>
{{ $t('misc.close') }}
</x-button>
@ -100,7 +100,7 @@ export default { name: 'list-setting-background' }
<script setup lang="ts">
import {ref, computed, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRoute, useRouter} from 'vue-router'
import {useRoute} from 'vue-router'
import debounce from 'lodash.debounce'
import BaseButton from '@/components/base/BaseButton.vue'
@ -127,7 +127,6 @@ const SEARCH_DEBOUNCE = 300
const {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore()
const route = useRoute()
const router = useRouter()
useTitle(() => t('list.background.title'))
@ -216,13 +215,13 @@ async function uploadBackground() {
success({message: t('list.background.success')})
}
async function removeBackground() {
async function removeBackground(onClose: () => void) {
const list = await listService.value.removeBackground(currentList.value)
await baseStore.handleSetCurrentList({list, forceUpdate: true})
namespaceStore.setListInNamespaceById(list)
listStore.setList(list)
success({message: t('list.background.removeSuccess')})
router.back()
onClose()
}
</script>

View File

@ -1,8 +1,5 @@
<template>
<modal
@close="$router.back()"
@submit="deleteList()"
>
<modal @submit="deleteList()">
<template #header><span>{{ $t('list.delete.header') }}</span></template>
<template #text>

View File

@ -6,6 +6,7 @@
@primary="save"
:tertiary="$t('misc.delete')"
@tertiary="$router.push({ name: 'list.settings.delete', params: { id: listId } })"
#default="{onClose}"
>
<div class="field">
<label class="label" for="title">{{ $t('list.title') }}</label>
@ -13,7 +14,7 @@
<input
:class="{ 'disabled': isLoading}"
:disabled="isLoading || undefined"
@keyup.enter="save"
@keyup.enter="save(onClose)"
class="input"
id="title"
:placeholder="$t('list.edit.titlePlaceholder')"
@ -33,7 +34,7 @@
<input
:class="{ 'disabled': isLoading}"
:disabled="isLoading || undefined"
@keyup.enter="save"
@keyup.enter="save(onClose)"
class="input"
id="identifier"
:placeholder="$t('list.edit.identifierPlaceholder')"
@ -71,7 +72,6 @@ export default { name: 'list-setting-edit' }
<script setup lang="ts">
import type {PropType} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import Editor from '@/components/input/AsyncEditor'
@ -92,17 +92,15 @@ const props = defineProps({
},
})
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
const {list, save: saveList, isLoading} = useList(props.listId)
useTitle(() => list?.title ? t('list.edit.title', {list: list.title}) : '')
async function save() {
async function save(onClose: () => void) {
await saveList()
await useBaseStore().handleSetCurrentList({list})
router.back()
onClose()
}
</script>

View File

@ -34,7 +34,7 @@
</x-button>
<x-button
v-if="n.isArchived"
:to="{name: 'namespace.settings.archive', params: {id: n.id}}"
:to="{name: 'namespace.settings.archive', params: {id: n.id} }"
class="is-pulled-right mr-4"
variant="secondary"
icon="archive"

View File

@ -1,8 +1,9 @@
<template>
<create-edit
:title="$t('namespace.create.title')"
@create="newNamespace()"
@create="newNamespace"
:primary-disabled="namespace.title === ''"
#default="{onClose}"
>
<div class="field">
<label class="label" for="namespaceTitle">{{ $t('namespace.attributes.title') }}</label>
@ -14,8 +15,8 @@
But with the input modal here since it autofocuses the input that input field catches the focus instead.
Hence we place the listener on the input field directly. -->
<input
@keyup.enter="newNamespace()"
@keyup.esc="$router.back()"
@keyup.enter="newNamespace(onClose)"
@keyup.esc="onClose"
class="input"
:placeholder="$t('namespace.attributes.titlePlaceholder')"
type="text"
@ -46,7 +47,6 @@
<script setup lang="ts">
import {ref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import Message from '@/components/misc/message.vue'
import CreateEdit from '@/components/misc/create-edit.vue'
@ -65,11 +65,10 @@ const namespace = ref<INamespace>(new NamespaceModel())
const namespaceService = shallowReactive(new NamespaceService())
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
useTitle(() => t('namespace.create.title'))
async function newNamespace() {
async function newNamespace(onClose: () => void) {
if (namespace.value.title === '') {
showError.value = true
return
@ -79,6 +78,6 @@ async function newNamespace() {
const newNamespace = await namespaceService.create(namespace.value)
useNamespaceStore().addNamespace(newNamespace)
success({message: t('namespace.create.success')})
router.back()
onClose()
}
</script>

View File

@ -1,8 +1,5 @@
<template>
<modal
@close="$router.back()"
@submit="archiveNamespace()"
>
<modal @submit="archiveNamespace">
<template #header><span>{{ title }}</span></template>
<template #text>
@ -23,7 +20,6 @@ export default { name: 'namespace-setting-archive' }
<script setup lang="ts">
import {watch, ref, computed, shallowReactive, type PropType} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {success} from '@/message'
@ -41,7 +37,6 @@ const props = defineProps({
},
})
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
const namespaceStore = useNamespaceStore()
@ -69,7 +64,7 @@ const title = computed(() => {
})
useTitle(title)
async function archiveNamespace() {
async function archiveNamespace(onClose: () => void) {
try {
const isArchived = !namespace.value.isArchived
const archivedNamespace = await namespaceService.update({
@ -83,7 +78,7 @@ async function archiveNamespace() {
: t('namespace.archive.unarchiveSuccess'),
})
} finally {
router.back()
onClose()
}
}
</script>

View File

@ -1,8 +1,5 @@
<template>
<modal
@close="$router.back()"
@submit="deleteNamespace()"
>
<modal @submit="deleteNamespace()">
<template #header><span>{{ title }}</span></template>
<template #text>

View File

@ -6,8 +6,9 @@
@primary="save"
:tertiary="$t('misc.delete')"
@tertiary="$router.push({ name: 'namespace.settings.delete', params: { id: $route.params.id } })"
#default="{onClose}"
>
<form @submit.prevent="save()">
<form @submit.prevent="save(onClose)">
<div class="field">
<label class="label" for="namespacetext">{{ $t('namespace.attributes.title') }}</label>
<div class="control">
@ -58,7 +59,6 @@
<script lang="ts" setup>
import {nextTick, ref, watch} from 'vue'
import {success} from '@/message'
import router from '@/router'
import AsyncEditor from '@/components/input/AsyncEditor'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
@ -110,11 +110,11 @@ async function loadNamespace() {
title.value = t('namespace.edit.title', {namespace: namespace.value.title})
}
async function save() {
async function save(onClose: () => void) {
const updatedNamespace = await namespaceService.value.update(namespace.value)
// Update the namespace in the parent
namespaceStore.setNamespaceById(updatedNamespace)
success({message: t('namespace.edit.success')})
router.back()
onClose()
}
</script>

View File

@ -1,4 +1,5 @@
<template>
<OptionalWrapper :is="isModal && Modal" variant="scrolling">
<div
class="loader-container task-view-container"
:class="{
@ -283,6 +284,22 @@
<comments :can-write="canWrite" :task-id="taskId"/>
</div>
<div class="column is-one-third action-buttons d-print-none" v-if="canWrite || isModal">
<x-button
v-if="isModal"
:to="{ name: 'task.detail', params: {id: task.id}, state: { backdropRoutePath: undefined }, force: true}"
variant="secondary"
>
<span class="icon is-small"><icon icon="expand"/></span>
Expand
</x-button>
<x-button
v-else
:to="overlayRoute"
variant="secondary"
>
<span class="icon is-small"><icon icon="expand"/></span>
Show as Overlay
</x-button>
<template v-if="canWrite">
<x-button
:class="{'is-success': !task.done}"
@ -442,15 +459,18 @@
</template>
</modal>
</div>
</OptionalWrapper>
</template>
<script lang="ts" setup>
import {ref, reactive, toRef, shallowReactive, computed, watch, nextTick, type PropType} from 'vue'
import {useRouter, type RouteLocation} from 'vue-router'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {unrefElement} from '@vueuse/core'
import cloneDeep from 'lodash.clonedeep'
import {handleRedirectRecord} from '@/router'
import TaskService from '@/services/task'
import TaskModel, {TASK_DEFAULT_COLOR} from '@/models/task'
@ -480,7 +500,10 @@ 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 OptionalWrapper from '@/components/base/OptionalWrapper.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import Modal from '@/components/misc/modal.vue'
import {uploadFile} from '@/helpers/attachments'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
@ -502,13 +525,12 @@ const props = defineProps({
type: Number as PropType<ITask['id']>,
required: true,
},
backdropView: {
type: String as PropType<RouteLocation['fullPath']>,
isModal: {
type: Boolean,
default: false,
},
})
defineEmits(['close'])
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
@ -521,6 +543,23 @@ const kanbanStore = useKanbanStore()
const task = reactive<ITask>(new TaskModel())
useTitle(toRef(task, 'title'))
const overlayRoute = computed(() => {
const resolvedbackdropRoute = router.resolve({ name: 'list.index', params: {listId: task.listId} })
// resolvedbackdropRoute.matched.forEach((record) => console.log(record.redirect))
// 'list.index' will always redirect, so we need to resolve the redirected route record
const backdropRoutePath = handleRedirectRecord(resolvedbackdropRoute)
return {
name: 'task.detail',
params: {id: task.id},
state: { backdropRoutePath },
// if force is not enabled it seems like vue router doesn't recognise the route change
force: true,
}
})
// 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,
@ -577,8 +616,6 @@ const color = computed(() => {
const hasAttachments = computed(() => attachmentStore.attachments.length > 0)
const isModal = computed(() => Boolean(props.backdropView))
function attachmentUpload(file: File, onSuccess?: (url: string) => void) {
return uploadFile(taskId.value, file, onSuccess)
}
@ -803,9 +840,11 @@ async function setPercentDone(percentDone: number) {
--primary-light: hsla(var(--primary-h), var(--primary-s), 73%, var(--primary-a));
padding-bottom: 0;
@media screen and (min-width: $desktop) {
padding-bottom: 1rem;
}
&:not(.is-modal) {
@media screen and (min-width: $desktop) {
padding-bottom: 1rem;
}
}
}
.task-view {

View File

@ -1,5 +1,6 @@
<template>
<create-edit
class="new-team"
:title="title"
@create="newTeam()"
:primary-disabled="team.name === ''"