fea32748e6
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
389 lines
10 KiB
Vue
389 lines
10 KiB
Vue
<!--
|
|
- @copyright Copyright (c) 2018 René Gieling <github@dartcafe.de>
|
|
-
|
|
- @author René Gieling <github@dartcafe.de>
|
|
-
|
|
- @license GNU AGPL version 3 or any later version
|
|
-
|
|
- This program is free software: you can redistribute it and/or modify
|
|
- it under the terms of the GNU Affero General Public License as
|
|
- published by the Free Software Foundation, either version 3 of the
|
|
- License, or (at your option) any later version.
|
|
-
|
|
- This program is distributed in the hope that it will be useful,
|
|
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
- GNU Affero General Public License for more details.
|
|
-
|
|
- You should have received a copy of the GNU Affero General Public License
|
|
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
-
|
|
-->
|
|
|
|
<template>
|
|
<div class="sharing">
|
|
<Multiselect id="ajax"
|
|
:clear-on-select="false"
|
|
:hide-selected="true"
|
|
:internal-search="false"
|
|
:loading="loading"
|
|
:multiple="true"
|
|
:options="options"
|
|
:placeholder="t('forms', 'User or group name …')"
|
|
:preselect-first="true"
|
|
:preserve-search="true"
|
|
:searchable="true"
|
|
:user-select="true"
|
|
label="displayName"
|
|
track-by="shareWith"
|
|
@search-change="asyncFind"
|
|
@select="addShare">
|
|
<template #noOptions>
|
|
{{ t('forms', 'No recommendations. Start typing.') }}
|
|
</template>
|
|
<template #noResult>
|
|
{{ noResultText }}
|
|
</template>
|
|
</Multiselect>
|
|
|
|
<TransitionGroup :css="false" tag="ul" class="shared-list">
|
|
<!-- TODO: Iterate two times, will be cleaner, one for users, one for groups -->
|
|
<li v-for="(item, index) in sortedShares" :key="item.shareWith + '-' + item.shareType" :data-index="index">
|
|
<UserDiv v-bind="item" />
|
|
<div class="options">
|
|
<a class="icon icon-delete svg delete-form" @click="removeShare(item)" />
|
|
</div>
|
|
</li>
|
|
</TransitionGroup>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import { generateOcsUrl } from '@nextcloud/router'
|
|
import { getCurrentUser } from '@nextcloud/auth'
|
|
import axios from '@nextcloud/axios'
|
|
import debounce from 'debounce'
|
|
import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
|
|
import ShareTypes from '../mixins/ShareTypes'
|
|
|
|
// TODO: replace with same design as core sharing
|
|
import UserDiv from './UserDiv'
|
|
|
|
export default {
|
|
components: {
|
|
Multiselect,
|
|
UserDiv,
|
|
},
|
|
|
|
mixins: [ShareTypes],
|
|
|
|
props: {
|
|
groupShares: {
|
|
type: Array,
|
|
default: () => ([]),
|
|
},
|
|
userShares: {
|
|
type: Array,
|
|
default: () => ([]),
|
|
},
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
query: '',
|
|
loading: false,
|
|
|
|
// TODO: have a global mixin for this, shared with server?
|
|
minSearchStringLength: parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0,
|
|
maxAutocompleteResults: parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 200,
|
|
|
|
// Search data
|
|
recommendations: [],
|
|
suggestions: [],
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
sortedShares() {
|
|
return [...this.userShares, ...this.groupShares].slice()
|
|
.sort(this.sortByDisplayname)
|
|
},
|
|
|
|
/**
|
|
* Is the search valid ?
|
|
* @returns {boolean}
|
|
*/
|
|
isValidQuery() {
|
|
return this.query && this.query.trim() !== '' && this.query.length > this.minSearchStringLength
|
|
},
|
|
|
|
/**
|
|
* Multiseelct options. Recommendations by default,
|
|
* direct search when search query is valid.
|
|
* Filter out existing shares
|
|
* @returns {Array}
|
|
*/
|
|
options() {
|
|
const shares = [...this.userShares, ...this.groupShares]
|
|
if (this.isValidQuery) {
|
|
// Filter out existing shares
|
|
return this.suggestions.filter(item => !shares.find(share => share.shareWith === item.shareWith && share.shareType === item.shareType))
|
|
}
|
|
// Filter out existing shares
|
|
return this.recommendations.filter(item => !shares.find(share => share.shareWith === item.shareWith && share.shareType === item.shareType))
|
|
},
|
|
|
|
noResultText() {
|
|
if (this.loading) {
|
|
return t('forms', 'Searching …')
|
|
}
|
|
return t('forms', 'No elements found.')
|
|
},
|
|
},
|
|
|
|
mounted() {
|
|
this.getRecommendations()
|
|
},
|
|
|
|
methods: {
|
|
removeShare(item) {
|
|
// Filter out the removed item
|
|
const users = this.userShares.filter(user => !(user.shareWith === item.shareWith && item.shareType === this.SHARE_TYPES.SHARE_TYPE_USER))
|
|
const groups = this.groupShares.filter(group => !(group.shareWith === item.shareWith && item.shareType === this.SHARE_TYPES.SHARE_TYPE_GROUP))
|
|
this.$emit('update:shares', { users, groups })
|
|
},
|
|
|
|
/**
|
|
* Add a new share and dispatch the change to the parent
|
|
* @param {Object} share the new share
|
|
*/
|
|
addShare(share) {
|
|
const users = this.userShares.slice()
|
|
const groups = this.groupShares.slice()
|
|
const newShare = {
|
|
shareWith: share.shareWith,
|
|
displayName: share.displayName,
|
|
shareType: share.shareType,
|
|
}
|
|
|
|
// TODO: detect if already present
|
|
|
|
if (share.shareType === this.SHARE_TYPES.SHARE_TYPE_USER) {
|
|
users.push(newShare)
|
|
} else if (share.shareType === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
|
|
groups.push(newShare)
|
|
}
|
|
|
|
console.debug('Adding new share', share, users, groups)
|
|
this.$emit('update:shares', { users, groups })
|
|
},
|
|
|
|
sortByDisplayname(a, b) {
|
|
if (a.displayName.toLowerCase() < b.displayName.toLowerCase()) return -1
|
|
if (a.displayName.toLowerCase() > b.displayName.toLowerCase()) return 1
|
|
return 0
|
|
},
|
|
|
|
async asyncFind(query) {
|
|
// save current query to check if we display
|
|
// recommendations or search results
|
|
this.query = query.trim()
|
|
if (this.isValidQuery) {
|
|
// start loading now to have proper ux feedback
|
|
// during the debounce
|
|
this.loading = true
|
|
await this.debounceGetSuggestions(query)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get suggestions
|
|
*
|
|
* @param {string} search the search query
|
|
*/
|
|
async getSuggestions(search) {
|
|
this.loading = true
|
|
|
|
const shareType = [
|
|
this.SHARE_TYPES.SHARE_TYPE_USER,
|
|
this.SHARE_TYPES.SHARE_TYPE_GROUP,
|
|
]
|
|
|
|
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1', 2) + 'sharees', {
|
|
params: {
|
|
format: 'json',
|
|
itemType: 'file',
|
|
search,
|
|
perPage: this.maxAutocompleteResults,
|
|
shareType,
|
|
},
|
|
})
|
|
|
|
const data = request.data.ocs.data
|
|
const exact = request.data.ocs.data.exact
|
|
data.exact = [] // removing exact from general results
|
|
|
|
// flatten array of arrays
|
|
const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
|
|
const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), [])
|
|
|
|
// remove invalid data and format to user-select layout
|
|
const exactSuggestions = this.filterOutUnwantedShares(rawExactSuggestions)
|
|
.map(share => this.formatForMultiselect(share))
|
|
// sort by type so we can get user&groups first...
|
|
.sort((a, b) => a.shareType - b.shareType)
|
|
const suggestions = this.filterOutUnwantedShares(rawSuggestions)
|
|
.map(share => this.formatForMultiselect(share))
|
|
// sort by type so we can get user&groups first...
|
|
.sort((a, b) => a.shareType - b.shareType)
|
|
|
|
this.suggestions = exactSuggestions.concat(suggestions)
|
|
|
|
this.loading = false
|
|
console.info('suggestions', this.suggestions)
|
|
},
|
|
|
|
/**
|
|
* Debounce getSuggestions
|
|
*
|
|
* @param {...*} args the arguments
|
|
*/
|
|
debounceGetSuggestions: debounce(function(...args) {
|
|
this.getSuggestions(...args)
|
|
}, 300),
|
|
|
|
/**
|
|
* Get the sharing recommendations
|
|
*/
|
|
async getRecommendations() {
|
|
this.loading = true
|
|
|
|
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1', 2) + 'sharees_recommended', {
|
|
params: {
|
|
format: 'json',
|
|
itemType: 'file',
|
|
},
|
|
})
|
|
|
|
const exact = request.data.ocs.data.exact
|
|
|
|
// flatten array of arrays
|
|
const rawRecommendations = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
|
|
|
|
// remove invalid data and format to user-select layout
|
|
this.recommendations = this.filterOutUnwantedShares(rawRecommendations)
|
|
.map(share => this.formatForMultiselect(share))
|
|
|
|
this.loading = false
|
|
console.info('recommendations', this.recommendations)
|
|
},
|
|
|
|
/**
|
|
* Filter out unwated shares
|
|
*
|
|
* @param {Object[]} shares the array of shares object
|
|
* @returns {Object[]}
|
|
*/
|
|
filterOutUnwantedShares(shares) {
|
|
return shares.reduce((arr, share) => {
|
|
// only check proper objects
|
|
if (typeof share !== 'object') {
|
|
return arr
|
|
}
|
|
|
|
try {
|
|
// filter out current user
|
|
if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER
|
|
&& share.value.shareWith === getCurrentUser().uid) {
|
|
return arr
|
|
}
|
|
|
|
// ALL GOOD
|
|
// let's add the suggestion
|
|
arr.push(share)
|
|
} catch {
|
|
return arr
|
|
}
|
|
return arr
|
|
}, [])
|
|
},
|
|
|
|
/**
|
|
* Format shares for the multiselect options
|
|
* @param {Object} result select entry item
|
|
* @returns {Object}
|
|
*/
|
|
formatForMultiselect(result) {
|
|
return {
|
|
shareWith: result.value.shareWith,
|
|
shareType: result.value.shareType,
|
|
user: result.uuid || result.value.shareWith,
|
|
isNoUser: result.value.shareType !== this.SHARE_TYPES.SHARE_TYPE_USER,
|
|
displayName: result.name || result.label,
|
|
icon: this.shareTypeToIcon(result.value.shareType),
|
|
// Vue unique binding to render within Multiselect's AvatarSelectOption
|
|
key: result.uuid || result.value.shareWith + '-' + result.value.shareType + '-' + result.name || result.label,
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get the icon based on the share type
|
|
* @param {number} type the share type
|
|
* @returns {string} the icon class
|
|
*/
|
|
shareTypeToIcon(type) {
|
|
switch (type) {
|
|
case this.SHARE_TYPES.SHARE_TYPE_GUEST:
|
|
// default is a user, other icons are here to differenciate
|
|
// themselves from it, so let's not display the user icon
|
|
// case this.SHARE_TYPES.SHARE_TYPE_REMOTE:
|
|
// case this.SHARE_TYPES.SHARE_TYPE_USER:
|
|
return 'icon-user'
|
|
case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
|
|
case this.SHARE_TYPES.SHARE_TYPE_GROUP:
|
|
return 'icon-group'
|
|
case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
|
|
return 'icon-mail'
|
|
case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
|
|
return 'icon-circle'
|
|
case this.SHARE_TYPES.SHARE_TYPE_ROOM:
|
|
return 'icon-room'
|
|
|
|
default:
|
|
return ''
|
|
}
|
|
},
|
|
|
|
},
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.sharing {
|
|
margin: 8px 8px 8px 36px;
|
|
}
|
|
|
|
.shared-list {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-start;
|
|
padding-top: 8px;
|
|
|
|
> li {
|
|
display: flex;
|
|
}
|
|
}
|
|
|
|
.options {
|
|
display: flex;
|
|
position: relative;
|
|
top: -12px;
|
|
left: -13px;
|
|
}
|
|
|
|
.multiselect {
|
|
width: 100% !important;
|
|
max-width: 100% !important;
|
|
}
|
|
</style>
|