1
0

Move list edit/namespace to separate pages and in a menu (#397)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/397
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad
2021-01-30 16:17:04 +00:00
parent 649714e8a9
commit e0be77d88f
54 changed files with 1773 additions and 974 deletions

View File

@ -64,25 +64,7 @@
{{ n.title }} ({{ n.lists.filter(l => !l.isArchived).length }})
</span>
</label>
<div class="actions">
<router-link
:key="n.id + 'list.create'"
:to="{ name: 'list.create', params: { id: n.id} }"
v-if="n.id > 0"
v-tooltip="'Add a new list in the ' + n.title + ' namespace'">
<span class="icon">
<icon icon="plus"/>
</span>
</router-link>
<router-link
:to="{name: 'namespace.edit', params: {id: n.id} }"
v-if="n.id > 0"
v-tooltip="'Settings'">
<span class="icon">
<icon icon="cog"/>
</span>
</router-link>
</div>
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div>
<input
:id="n.id + 'checker'"
@ -118,6 +100,7 @@
<icon :icon="['far', 'star']" v-else/>
</span>
</router-link>
<list-settings-dropdown :list="l"/>
</li>
</template>
</ul>
@ -134,9 +117,15 @@
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST, MENU_ACTIVE, LOADING, LOADING_MODULE} from '@/store/mutation-types'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown'
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
export default {
name: 'navigation',
components: {
ListSettingsDropdown,
NamespaceSettingsDropdown,
},
computed: mapState({
namespaces(state) {
return state.namespaces.namespaces.filter(n => !n.isArchived)

View File

@ -31,58 +31,49 @@
class="title">
{{ currentList.title === '' ? 'Loading...' : currentList.title }}
</h1>
<router-link
:to="{ name: 'list.edit', params: { id: currentList.id } }"
class="icon"
v-if="canWriteCurrentList">
<icon icon="cog" size="2x"/>
</router-link>
<list-settings-dropdown v-if="canWriteCurrentList" :list="currentList"/>
</div>
<div class="navbar-end">
<update/>
<div class="user">
<img :src="userAvatar" alt="" class="avatar"/>
<div class="dropdown is-right is-active">
<div class="dropdown-trigger">
<x-button
@click.stop="userMenuActive = !userMenuActive"
type="secondary"
<dropdown class="is-right">
<template v-slot:trigger>
<x-button
type="secondary"
:shadow="false">
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
<span class="icon is-small">
<icon icon="chevron-down"/>
</span>
</x-button>
</div>
<transition name="fade">
<div class="dropdown-menu" v-if="userMenuActive">
<div class="dropdown-content">
<router-link :to="{name: 'user.settings'}" class="dropdown-item">
Settings
</router-link>
<a
:href="imprintUrl"
class="dropdown-item"
target="_blank"
v-if="imprintUrl">
Imprint
</a>
<a
:href="privacyPolicyUrl"
class="dropdown-item"
target="_blank"
v-if="privacyPolicyUrl">
Privacy policy
</a>
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item">Keyboard
Shortcuts</a>
<a @click="logout()" class="dropdown-item">
Logout
</a>
</div>
</div>
</transition>
</div>
</template>
<router-link :to="{name: 'user.settings'}" class="dropdown-item">
Settings
</router-link>
<a
:href="imprintUrl"
class="dropdown-item"
target="_blank"
v-if="imprintUrl">
Imprint
</a>
<a
:href="privacyPolicyUrl"
class="dropdown-item"
target="_blank"
v-if="privacyPolicyUrl">
Privacy policy
</a>
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item">Keyboard
Shortcuts</a>
<a @click="logout()" class="dropdown-item">
Logout
</a>
</dropdown>
</div>
</div>
</nav>
@ -93,21 +84,16 @@ import {mapState} from 'vuex'
import {CURRENT_LIST} from '@/store/mutation-types'
import Rights from '@/models/rights.json'
import Update from '@/components/home/update'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown'
import Dropdown from '@/components/misc/dropdown'
export default {
name: 'topNavigation',
data() {
return {
userMenuActive: false,
}
},
components: {
Dropdown,
ListSettingsDropdown,
Update,
},
created() {
// This will hide the menu once clicked outside of it
this.$nextTick(() => document.addEventListener('click', () => this.userMenuActive = false))
},
computed: mapState({
userInfo: state => state.auth.info,
userAvatar: state => state.auth.avatarUrl,

View File

@ -0,0 +1,106 @@
<template>
<dropdown>
<template v-if="isSavedFilter">
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.edit`, params: { listId: list.id } }"
icon="pen"
>
Edit
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.delete`, params: { listId: list.id } }"
icon="trash-alt"
>
Delete
</dropdown-item>
</template>
<template v-else-if="list.isArchived">
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.archive`, params: { listId: list.id } }"
icon="archive"
>
Un-Archive
</dropdown-item>
</template>
<template v-else>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.edit`, params: { listId: list.id } }"
icon="pen"
>
Edit
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.background`, params: { listId: list.id } }"
v-if="backgroundsEnabled"
icon="image"
>
Set background
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.share`, params: { listId: list.id } }"
icon="share-alt"
>
Share
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.duplicate`, params: { listId: list.id } }"
icon="paste"
>
Duplicate
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.archive`, params: { listId: list.id } }"
icon="archive"
>
Archive
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.delete`, params: { listId: list.id } }"
icon="trash-alt"
class="has-text-danger"
>
Delete
</dropdown-item>
</template>
</dropdown>
</template>
<script>
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
import Dropdown from '@/components/misc/dropdown'
import DropdownItem from '@/components/misc/dropdown-item'
export default {
name: 'list-settings-dropdown',
components: {
DropdownItem,
Dropdown,
},
props: {
list: {
required: true,
},
},
computed: {
backgroundsEnabled() {
return this.$store.state.config.enabledBackgroundProviders.length > 0
},
listRoutePrefix() {
let name = 'list'
if (this.$route.name.startsWith('list.')) {
name = this.$route.name
}
if (this.isSavedFilter) {
name = name.replace('list.', 'filter.')
}
return name
},
isSavedFilter() {
return getSavedFilterIdFromListId(this.list.id) > 0
},
},
}
</script>

View File

@ -1,172 +0,0 @@
<template>
<card
:class="{ 'is-loading': backgroundService.loading}"
class="list-background-setting loader-container"
v-if="uploadBackgroundEnabled || unsplashBackgroundEnabled"
title="Set list background"
>
<div class="mb-4" v-if="uploadBackgroundEnabled">
<input
@change="uploadBackground"
accept="image/*"
class="is-hidden"
ref="backgroundUploadInput"
type="file"
/>
<x-button
:loading="backgroundUploadService.loading"
@click="$refs.backgroundUploadInput.click()"
type="primary"
>
Choose a background from your pc
</x-button>
</div>
<template v-if="unsplashBackgroundEnabled">
<input
:class="{'is-loading': backgroundService.loading}"
@keyup="() => newBackgroundSearch()"
class="input is-expanded"
placeholder="Search for a background..."
type="text"
v-model="backgroundSearchTerm"
/>
<p class="unsplash-link"><a href="https://unsplash.com" target="_blank">Powered by Unsplash</a></p>
<div class="image-search-result">
<a
:key="im.id"
:style="{'background-image': `url(${backgroundThumbs[im.id]})`}"
@click="() => setBackground(im.id)"
class="image"
v-for="im in backgroundSearchResult">
<a :href="`https://unsplash.com/@${im.info.author}`" target="_blank" class="info">
{{ im.info.authorName }}
</a>
</a>
</div>
<x-button
:disabled="backgroundService.loading"
@click="() => searchBackgrounds(currentPage + 1)"
class="is-load-more-button mt-4"
:shadow="false"
type="secondary"
v-if="backgroundSearchResult.length > 0"
>
<template v-if="backgroundService.loading">
Loading...
</template>
<template v-else>
Load more photos
</template>
</x-button>
</template>
</card>
</template>
<script>
import BackgroundUnsplashService from '../../../services/backgroundUnsplash'
import BackgroundUploadService from '../../../services/backgroundUpload'
import {CURRENT_LIST} from '@/store/mutation-types'
export default {
name: 'background-settings',
data() {
return {
backgroundSearchTerm: '',
backgroundSearchResult: [],
backgroundService: null,
backgroundThumbs: {},
currentPage: 1,
backgroundSearchTimeout: null,
backgroundUploadService: null,
}
},
props: {
listId: {
default: 0,
required: true,
},
},
computed: {
unsplashBackgroundEnabled() {
return this.$store.state.config.enabledBackgroundProviders.includes('unsplash')
},
uploadBackgroundEnabled() {
return this.$store.state.config.enabledBackgroundProviders.includes('upload')
},
},
created() {
this.backgroundService = new BackgroundUnsplashService()
this.backgroundUploadService = new BackgroundUploadService()
// Show the default collection of backgrounds
this.newBackgroundSearch()
},
methods: {
newBackgroundSearch() {
if (!this.unsplashBackgroundEnabled) {
return
}
// This is an extra method to reset a few things when searching to not break loading more photos.
this.$set(this, 'backgroundSearchResult', [])
this.$set(this, 'backgroundThumbs', {})
this.searchBackgrounds()
},
searchBackgrounds(page = 1) {
if (this.backgroundSearchTimeout !== null) {
clearTimeout(this.backgroundSearchTimeout)
}
// We're using the timeout to not search on every keypress but with a 300ms delay.
// If another key is pressed within these 300ms, the last search request is dropped and a new one is scheduled.
this.backgroundSearchTimeout = setTimeout(() => {
this.currentPage = page
this.backgroundService.getAll({}, {s: this.backgroundSearchTerm, p: page})
.then(r => {
this.backgroundSearchResult = this.backgroundSearchResult.concat(r)
r.forEach(b => {
this.backgroundService.thumb(b)
.then(t => {
this.$set(this.backgroundThumbs, b.id, t)
})
})
})
.catch(e => {
this.error(e, this)
})
}, 300)
},
setBackground(backgroundId) {
// Don't set a background if we're in the process of setting one
if (this.backgroundService.loading) {
return
}
this.backgroundService.update({id: backgroundId, listId: this.listId})
.then(l => {
this.$store.commit(CURRENT_LIST, l)
this.$store.commit('namespaces/setListInNamespaceById', l)
this.success({message: 'The background has been set successfully!'}, this)
})
.catch(e => {
this.error(e, this)
})
},
uploadBackground() {
if (this.$refs.backgroundUploadInput.files.length === 0) {
return
}
this.backgroundUploadService.create(this.listId, this.$refs.backgroundUploadInput.files[0])
.then(l => {
this.$store.commit(CURRENT_LIST, l)
this.$store.commit('namespaces/setListInNamespaceById', l)
this.success({message: 'The background has been set successfully!'}, this)
})
.catch(e => {
this.error(e, this)
})
},
},
}
</script>

View File

@ -10,7 +10,7 @@
</span>
</a>
</header>
<div class="card-content" :class="{'p-0': !padding}">
<div class="card-content loader-container" :class="{'p-0': !padding, 'is-loading': loading}">
<div :class="{'content': hasContent}">
<slot></slot>
</div>
@ -46,6 +46,10 @@ export default {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
},
}
</script>

View File

@ -0,0 +1,85 @@
<template>
<modal @close="$router.back()" :overflow="true" :wide="wide">
<card
:title="title"
:shadow="false"
:padding="false"
class="has-text-left has-overflow"
:has-close="true"
close-icon="times"
@close="$router.back()"
:loading="loading"
>
<div class="p-4">
<slot></slot>
</div>
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
<x-button
:shadow="false"
type="tertary"
@click.prevent.stop="$emit('tertary')"
v-if="tertary !== ''"
>
{{ tertary }}
</x-button>
<x-button
type="secondary"
@click.prevent.stop="$router.back()"
>
Cancel
</x-button>
<x-button
type="primary"
@click.prevent.stop="primary"
:icon="primaryIcon"
:disabled="primaryDisabled"
v-if="primaryLabel !== ''"
>
{{ primaryLabel }}
</x-button>
</footer>
</card>
</modal>
</template>
<script>
export default {
name: 'create-edit',
props: {
title: {
type: String,
default: '',
},
primaryLabel: {
type: String,
default: 'Create',
},
primaryIcon: {
type: String,
default: 'plus',
},
primaryDisabled: {
type: Boolean,
default: false,
},
tertary: {
type: String,
default: '',
},
wide: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
},
methods: {
primary() {
this.$emit('create')
this.$emit('primary')
},
},
}
</script>

View File

@ -1,55 +0,0 @@
<template>
<modal @close="$router.back()" :overflow="true">
<card
:title="title"
:shadow="false"
:padding="false"
class="has-text-left has-overflow"
:has-close="true"
close-icon="times"
@close="$router.back()"
>
<div class="p-4">
<slot></slot>
</div>
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
<x-button
:shadow="false"
type="secondary"
@click.prevent.stop="$router.back()"
>
Cancel
</x-button>
<x-button
:shadow="false"
type="primary"
@click.prevent.stop="$emit('create')"
icon="plus"
:disabled="createDisabled"
>
{{ createLabel }}
</x-button>
</footer>
</card>
</modal>
</template>
<script>
export default {
name: 'create',
props: {
title: {
type: String,
default: '',
},
createLabel: {
type: String,
default: 'Create',
},
createDisabled: {
type: Boolean,
default: false,
},
},
}
</script>

View File

@ -0,0 +1,28 @@
<template>
<router-link
:to="to"
class="dropdown-item">
<span class="icon" v-if="icon !== ''">
<icon :icon="icon"/>
</span>
<span>
<slot></slot>
</span>
</router-link>
</template>
<script>
export default {
name: 'dropdown-item',
props: {
to: {
required: true,
},
icon: {
type: String,
required: false,
default: '',
}
},
}
</script>

View File

@ -0,0 +1,50 @@
<template>
<div class="dropdown is-right is-active" ref="dropdown">
<div class="dropdown-trigger" @click="open = !open">
<slot name="trigger">
<icon :icon="triggerIcon" class="icon"/>
</slot>
</div>
<transition name="fade">
<div class="dropdown-menu" v-if="open">
<div class="dropdown-content">
<slot></slot>
</div>
</div>
</transition>
</div>
</template>
<script>
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
export default {
name: 'dropdown',
data() {
return {
open: false,
}
},
mounted() {
document.addEventListener('click', this.hide)
},
beforeDestroy() {
document.removeEventListener('click', this.hide)
},
props: {
triggerIcon: {
type: String,
default: 'ellipsis-h',
},
},
methods: {
hide(e) {
if (this.open) {
closeWhenClickedOutside(e, this.$refs.dropdown, () => {
this.open = false
})
}
},
},
}
</script>

View File

@ -0,0 +1,11 @@
<template>
<p class="has-text-centered has-text-grey is-italic p-4 mb-4">
<slot></slot>
</p>
</template>
<script>
export default {
name: 'nothing'
}
</script>

View File

@ -2,7 +2,7 @@
<transition name="modal">
<div class="modal-mask">
<div class="modal-container" @click.self.prevent.stop="$emit('close')">
<div class="modal-content" :class="{'has-overflow': overflow}">
<div class="modal-content" :class="{'has-overflow': overflow, 'is-wide': wide}">
<slot>
<div class="header">
<slot name="header"></slot>
@ -49,6 +49,10 @@ export default {
type: Boolean,
default: false,
},
wide: {
type: Boolean,
default: false,
},
},
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<dropdown>
<template v-if="namespace.isArchived">
<dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
icon="archive"
>
Un-Archive
</dropdown-item>
</template>
<template v-else>
<dropdown-item
:to="{ name: 'namespace.settings.edit', params: { id: namespace.id } }"
icon="pen"
>
Edit
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.share', params: { id: namespace.id } }"
icon="share-alt"
>
Share
</dropdown-item>
<dropdown-item
:to="{ name: 'list.create', params: { id: namespace.id } }"
icon="plus"
>
New list
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
icon="archive"
>
Archive
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.delete', params: { id: namespace.id } }"
icon="trash-alt"
class="has-text-danger"
>
Delete
</dropdown-item>
</template>
</dropdown>
</template>
<script>
import Dropdown from '@/components/misc/dropdown'
import DropdownItem from '@/components/misc/dropdown-item'
export default {
name: 'namespace-settings-dropdown',
components: {
DropdownItem,
Dropdown,
},
props: {
namespace: {
required: true,
},
},
}
</script>

View File

@ -1,5 +1,6 @@
<template>
<card title="Share links" class="is-fullwidth" :padding="false">
<div>
<p class="has-text-weight-bold">Share Links</p>
<div class="sharables-list">
<div class="p-4">
<p>Share with a link:</p>
@ -21,7 +22,7 @@
</div>
</div>
<table
class="table is-striped is-hoverable is-fullwidth link-share-list"
class="table has-actions is-striped is-hoverable is-fullwidth link-share-list"
v-if="linkShares.length > 0"
>
<thead>
@ -112,7 +113,7 @@
</p>
</modal>
</transition>
</card>
</div>
</template>
<script>

View File

@ -1,10 +1,11 @@
<template>
<card class="is-fullwidth has-overflow" :title="`Shared with these ${shareType}s`" :padding="false">
<div class="p-4" v-if="userIsAdmin">
<div>
<p class="has-text-weight-bold">Shared with these {{ shareType }}s</p>
<div v-if="userIsAdmin">
<div class="field has-addons">
<p
class="control is-expanded"
v-bind:class="{ 'is-loading': searchService.loading }"
:class="{ 'is-loading': searchService.loading }"
>
<multiselect
:loading="searchService.loading"
@ -20,7 +21,8 @@
</p>
</div>
</div>
<table class="table is-striped is-hoverable is-fullwidth">
<table class="table has-actions is-striped is-hoverable is-fullwidth mb-4" v-if="sharables.length > 0">
<tbody>
<tr :key="s.id" v-for="s in sharables">
<template v-if="shareType === 'user'">
@ -105,6 +107,10 @@
</tbody>
</table>
<nothing v-else>
Not shared with any {{ shareType }} yet.
</nothing>
<transition name="modal">
<modal
@close="showDeleteModal = false"
@ -121,7 +127,7 @@
</p>
</modal>
</transition>
</card>
</div>
</template>
<script>
@ -143,6 +149,7 @@ import TeamModel from '../../models/team'
import rights from '../../models/rights'
import Multiselect from '@/components/input/multiselect'
import Nothing from '@/components/misc/nothing'
export default {
name: 'userTeamShare',
@ -182,6 +189,7 @@ export default {
}
},
components: {
Nothing,
Multiselect,
},
computed: mapState({

View File

@ -12,6 +12,8 @@ export default {
pages: [],
currentPage: 0,
loadedList: null,
showTaskSearch: false,
searchTerm: '',
@ -53,6 +55,17 @@ export default {
return
}
const list = {listId: parseInt(this.$route.params.listId)}
const currentList = {
id: list.listId,
params: params,
search: search,
}
if (JSON.stringify(currentList) === JSON.stringify(this.loadedList)) {
return
}
this.$set(this, 'tasks', [])
if (params === null) {
@ -62,7 +75,8 @@ export default {
if (search !== '') {
params.s = search
}
this.taskCollectionService.getAll({listId: this.$route.params.listId}, params, page)
this.taskCollectionService.getAll(list, params, page)
.then(r => {
this.$set(this, 'tasks', r)
this.$set(this, 'pages', [])
@ -95,6 +109,8 @@ export default {
isEllipsis: false,
})
}
this.loadedList = currentList
})
.catch(e => {
this.error(e, this)