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">
dpschen marked this conversation as resolved Outdated

This component makes it possible to optionally wrap around another component. 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.

This component makes it possible to optionally wrap around another component. 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.
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<{
dpschen marked this conversation as resolved Outdated

Remove this

Remove this
/**
* 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" />

Every modal component now has to provide it's own modal.

This makes it possible for us to have different modal implementations. Currently we do this by changing the type prop of the modal. It might be easier for us if we create something like a BaseModal to abstract the general modal functionality and then use that to create styled Modals with specific functionality. For example we coudl create a dedicated Dialog modal (this might actually be the same as the current create-edit, I'm not sure here if that was the intended use @konrad).

Every modal component now has to provide it's own modal. This makes it possible for us to have different modal implementations. Currently we do this by changing the `type` prop of the modal. It might be easier for us if we create something like a `BaseModal` to abstract the general modal functionality and then use that to create styled Modals with specific functionality. For example we coudl create a dedicated `Dialog` modal (this might actually be the same as the current `create-edit`, I'm not sure here if that was the intended use @konrad).

That sounds like it could be a good idea.

IIRC my main goal with the create-edit component was to be able to easily re-use a shell for creating or editing.

That sounds like it could be a good idea. IIRC my main goal with the `create-edit` component was to be able to easily re-use a shell for creating or editing.
<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 }">

I removed the @close event definitions on all internal modals because what happens when that event get's fired should instead be defined by the parent. By removing this the parents onClose / @close should automatically be passed to the <modal>.
By passing the onClose then to the slot content, the slot is still able to use the method.

I removed the `@close` event definitions on all internal modals because what happens when that event get's fired should instead be defined by the parent. By removing this the parents `onClose` / `@close` should automatically be passed to the `<modal>`. By passing the `onClose` then to the slot content, the slot is still able to use the method.
<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) {

We pass onClose as a callback parameter to the primary emit so that it can reuse the onClose after the primary action finished (unsure if that makes sense).

We pass `onClose` as a callback parameter to the `primary` emit so that it can reuse the `onClose` after the primary action finished (unsure if that makes sense).
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">

The appear transition is currently broken. Fix this.

The appear transition is currently broken. Fix this.
<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
})

FIXME: either we use an implicit attrs.onClose or we explicit define a close emit. Both should not be mixed.

FIXME: either we use an implicit `attrs.onClose` or we explicit define a `close` emit. Both should not be mixed.
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>()

Something was broken here: since we checked for historyState.value in the if condition it could never have a value in the else cause.

Something was broken here: since we checked for `historyState.value` in the `if` condition it could never have a value in the `else` cause.
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)"

We should not need to use .prevent and .stop with the vueuse onClickOutside.

We should not need to use `.prevent` and `.stop` with the vueuse `onClickOutside`.
>
{{ $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">

By using the OptionalWrapper we can use components that can be used with or without Modal

By using the OptionalWrapper we can use components that can be used with or without Modal
<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 === ''"