Merge pull request #410 from nextcloud/sharing

Implement sharing calendars / task lists
This commit is contained in:
Raimund Schlüßler 2019-05-16 17:50:15 +02:00 committed by GitHub
commit 5648b2fd7f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 608 additions and 43 deletions

View file

@ -0,0 +1,64 @@
/**
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Team Popcorn <teampopcornberlin@gmail.com>
* @author Raimund Schlüßler <raimund.schluessler@mailbox.org>
*
* @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/>.
*
*/
.calendar {
display: flex;
flex-wrap: wrap;
align-items: center;
white-space: nowrap;
text-overflow: ellipsis;
> a:first-of-type {
// put actions at the end
margin-left: auto;
}
&__name {
display: block;
flex: 0 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__share,
&__menu .icon-more {
width: 44px;
height: 44px;
opacity: .5;
cursor: pointer;
&:hover,
&:focus,
&:active {
opacity: .7;
}
}
&__share {
&--shared {
opacity: .7;
}
}
&--disabled &__name {
opacity: .5;
}
}

View file

@ -0,0 +1,85 @@
/**
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Team Popcorn <teampopcornberlin@gmail.com>
* @author Raimund Schlüßler <raimund.schluessler@mailbox.org>
*
* @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/>.
*
*/
.calendar-sharee {
padding: 0 5px;
display: inline-flex;
align-items: center;
width: 100%;
.icon {
margin-right: 5px;
opacity: 0.2;
width: 16px;
height: 16px;
display: inline-block;
margin-bottom: 2px;
&.icon-loading-small {
opacity: 1;
}
}
&__identifier {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
vertical-align: top;
opacity: 0.7;
}
&__utils {
padding: 0 !important;
float: right;
position: relative !important;
display: inline-flex;
align-items: center;
flex-shrink: 0;
height: 20px;
.icon-delete {
display: inline-block;
width: 20px;
height: 20px;
opacity: 0.4;
margin-bottom: 2px;
margin-left: 4px;
&:hover {
box-shadow: unset !important;
}
}
// loading state
&--disabled {
opacity: .2 !important;
}
.checkbox + label {
padding: 0 !important;
}
label {
opacity: 0.7;
}
}
}

View file

@ -0,0 +1,62 @@
/**
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Team Popcorn <teampopcornberlin@gmail.com>
* @author Raimund Schlüßler <raimund.schluessler@mailbox.org>
*
* @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/>.
*
*/
.calendar-shares {
width: calc(100% - 6px);
margin: 6px;
&__list {
margin-top: 8px;
margin-bottom: 12px;
display: flex;
flex-direction: column;
}
&__shareematch--bold {
font-weight: bold;
}
.icon-loading::after {
top: 70%;
left: 95%;
height: 14px;
width: 14px;
}
.multiselect {
width: inherit;
margin: 0;
.multiselect__tags:focus-within,
.multiselect__tags:hover {
border-color: var(--color-primary-element);
}
&:not(.showContent) .multiselect__content-wrapper {
display: none;
}
.multiselect__content-wrapper {
z-index: 101 !important;
}
}
}

View file

@ -47,8 +47,11 @@
display: none;
}
&.list:not(.active) .app-navigation-entry-utils-menu-button {
display: none;
&.list:not(.active) {
.app-navigation-entry-utils-menu-button,
.calendar__share {
display: none;
}
}
.app-navigation-entry-edit {

View file

@ -24,3 +24,7 @@
@import './src/sprites-bw';
@import './src/style';
@import './src/markdown';
@import './src/Calendars/Calendar.scss';
@import './src/Calendars/CalendarShares.scss';
@import './src/Calendars/CalendarSharee.scss';

View file

@ -0,0 +1,140 @@
<!--
@copyright Copyright (c) 2018 Team Popcorn <teampopcornberlin@gmail.com>
@author Team Popcorn <teampopcornberlin@gmail.com>
@author Raimund Schlüßler <raimund.schluessler@mailbox.org>
@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="calendar-shares">
<multiselect
id="users-groups-search"
:options="usersOrGroups"
:searchable="true"
:internal-search="false"
:max-height="600"
:show-no-results="true"
:placeholder="placeholder"
:class="{ 'showContent': inputGiven, 'icon-loading': isLoading }"
:user-select="true"
open-direction="bottom"
track-by="user"
label="user"
@search-change="findSharee"
@input="shareCalendar"
/>
<!-- list of user or groups calendar is shared with -->
<ul v-if="calendar.shares.length > 0" class="calendar-shares__list">
<calendar-sharee v-for="sharee in calendar.shares" :key="sharee.uri"
:sharee="sharee" :calendar="calendar"
/>
</ul>
</div>
</template>
<script>
import client from '../services/cdav'
import { Multiselect } from 'nextcloud-vue'
import calendarSharee from './CalendarSharee'
// import debounce from 'debounce'
export default {
name: 'ShareCalendar',
components: {
calendarSharee,
Multiselect
},
props: {
calendar: {
type: Object,
default() {
return {}
}
}
},
data() {
return {
isLoading: false,
inputGiven: false,
usersOrGroups: []
}
},
computed: {
placeholder() {
return t('tasks', 'Share with users or groups')
},
noResult() {
return t('tasks', 'No users or groups')
}
},
mounted() {
// This ensures that the multiselect input is in focus as soon as the user clicks share
document.getElementById('users-groups-search').focus()
},
methods: {
/**
* Share calendar
*
* @param {Object} data destructuring object
* @param {string} data.user the userId
* @param {string} data.displayName the displayName
* @param {string} data.uri the sharing principalScheme uri
* @param {boolean} data.isGroup is this a group ?
*/
shareCalendar({ user, displayName, uri, isGroup }) {
let calendar = this.calendar
uri = decodeURI(uri)
user = decodeURI(user)
this.$store.dispatch('shareCalendar', { calendar, user, displayName, uri, isGroup })
},
/**
* Use the cdav client call to find matches to the query from the existing Users & Groups
*
* @param {string} query The query string
*/
findSharee: async function(query) {
this.isLoading = true
this.usersOrGroups = []
if (query.length > 0) {
const results = await client.principalPropertySearchByDisplayname(query)
this.usersOrGroups = results.reduce((list, result) => {
if (['GROUP', 'INDIVIDUAL'].indexOf(result.calendarUserType) > -1) {
const isGroup = result.calendarUserType === 'GROUP'
list.push({
user: result[isGroup ? 'groupId' : 'userId'],
displayName: result.displayname,
icon: isGroup ? 'icon-group' : 'icon-user',
uri: result.principalScheme,
isGroup
})
}
return list
}, [])
this.isLoading = false
this.inputGiven = true
} else {
this.inputGiven = false
this.isLoading = false
}
}
}
}
</script>

View file

@ -0,0 +1,129 @@
<!--
@copyright Copyright (c) 2018 Team Popcorn <teampopcornberlin@gmail.com>
@author Team Popcorn <teampopcornberlin@gmail.com>
@author Raimund Schlüßler <raimund.schluessler@mailbox.org>
@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>
<li class="calendar-sharee">
<span :class="{
'icon-loading-small': loading,
'icon-group': sharee.isGroup && !loading,
'icon-user': !sharee.isGroup && !loading
}" class="icon"
/>
<span class="calendar-sharee__identifier">
{{ sharee.displayName }}
</span>
<span class="calendar-sharee__utils">
<input
:id="uid"
:checked="writeable"
:disabled="loading"
class="checkbox"
name="editable"
type="checkbox"
@change="editSharee"
>
<label :for="uid">
{{ t('tasks', 'can edit') }}
</label>
<a :class="{'calendar-sharee__utils--disabled': loading}" href="#"
title="Delete"
class="icon-delete"
@click="deleteSharee"
/>
</span>
</li>
</template>
<script>
export default {
name: 'ShareSharee',
props: {
calendar: {
type: Object,
required: true
},
sharee: {
type: Object,
required: true
}
},
data() {
return {
loading: false
}
},
computed: {
writeable() {
return this.sharee.writeable
},
// generated id for this sharee
uid() {
return this.sharee.id + this.calendar.id + Math.floor(Math.random() * 1000)
}
},
methods: {
async deleteSharee() {
if (this.loading) {
return false
}
this.loading = true
try {
await this.$store.dispatch('removeSharee', {
calendar: this.calendar,
uri: this.sharee.uri
})
} catch (error) {
console.error(error)
OC.Notification.showTemporary(t('tasks', 'Unable to delete the share.'))
} finally {
this.loading = false
}
},
async editSharee() {
if (this.loading) {
return false
}
this.loading = true
try {
await this.$store.dispatch('toggleShareeWritable', {
calendar: this.calendar,
uri: this.sharee.uri,
writeable: !this.sharee.writeable
})
} catch (error) {
console.error(error)
OC.Notification.showTemporary(t('tasks', 'Unable to change permissions.'))
} finally {
this.loading = false
}
}
}
}
</script>

View file

@ -65,11 +65,19 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
{{ calendar.displayName }}
</span>
</a>
<div class="app-navigation-entry-utils">
<ul>
<li class="app-navigation-entry-utils-counter">
{{ calendarCount(calendar.id) | counterFormatter }}
</li>
<!-- sharing button -->
<li v-if="!calendar.readOnly" v-tooltip.top="sharedWithTooltip(calendar)"
:class="{'calendar__share--shared': hasShares(calendar)}"
:title="sharedWithTooltip(calendar)" href="#"
class="calendar__share icon-shared reactive" @click="toggleShare(calendar)"
/>
<Popover tag="li" class="app-navigation-entry-utils-menu-button reactive">
<ul>
<li v-if="!calendar.readOnly">
@ -101,6 +109,10 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
</Popover>
</ul>
</div>
<!-- sharing input -->
<ShareCalendar v-if="shareOpen == calendar.id && !calendar.readOnly" :calendar="calendar" />
<div :class="{error: nameError}" class="app-navigation-entry-edit name">
<form>
<input v-model="newCalendarName"
@ -175,6 +187,7 @@ import { mapState, mapGetters, mapActions } from 'vuex'
import Colorpicker from './Colorpicker'
import PopoverMenu from './PopoverMenu'
import Confirmation from './Confirmation'
import ShareCalendar from './CalendarShare'
import ClickOutside from 'vue-click-outside'
@ -182,7 +195,8 @@ export default {
components: {
'Colorpicker': Colorpicker,
'Popover': PopoverMenu,
'Confirmation': Confirmation
'Confirmation': Confirmation,
ShareCalendar
},
directives: {
ClickOutside
@ -202,6 +216,7 @@ export default {
data() {
return {
editing: '',
shareOpen: '',
copySuccess: false,
copied: false,
creating: false,
@ -253,10 +268,34 @@ export default {
() => document.querySelector('#list_' + calendar.id + ' input.edit').focus()
)
},
toggleShare: function(calendar) {
if (this.shareOpen === calendar.id) {
this.shareOpen = ''
} else {
this.shareOpen = calendar.id
}
},
hasShares: function(calendar) {
return calendar.shares.length > 0
},
// info tooltip about number of shares
sharedWithTooltip: function(calendar) {
return this.hasShares(calendar)
? n('tasks',
'Shared with {num} entity',
'Shared with {num} entities',
calendar.shares.length, {
num: calendar.shares.length
})
: '' // disable the tooltip
},
resetView: function(calendar) {
if (this.editing === calendar.id) {
this.editing = ''
}
if (this.shareOpen === calendar.id) {
this.shareOpen = ''
}
this.tooltipTarget = ''
},
copyCalDAVUrl(event, calendar) {

View file

@ -75,11 +75,32 @@ export function mapDavCollectionToCalendar(calendar) {
tasks: {},
url: calendar.url,
dav: calendar,
shares: calendar.shares.map(sharee => Object.assign({}, mapDavShareeToSharee(sharee))),
supportsTasks: calendar.components.includes('VTODO'),
loadedCompleted: false,
}
}
/**
* Maps a dav collection to the sharee array
*
* @param {Object} sharee The sharee object from the cdav library shares
* @returns {Object}
*/
export function mapDavShareeToSharee(sharee) {
const id = sharee.href.split('/').slice(-1)[0]
const name = sharee['common-name']
? sharee['common-name']
: id
return {
displayName: name,
id: id,
writeable: sharee.access[0].endsWith('read-write'),
isGroup: sharee.href.startsWith('principal:principals/groups/'),
uri: sharee.href
}
}
const getters = {
/**
@ -309,16 +330,19 @@ const mutations = {
* @param {Object} state The store data
* @param {Object} data Destructuring object
* @param {Calendar} data.calendar The calendar
* @param {String} data.sharee The sharee
* @param {String} data.id The id
* @param {Boolean} data.group The group
* @param {String} data.user The userId
* @param {String} data.displayName The displayName
* @param {String} data.uri The sharing principalScheme uri
* @param {Boolean} data.isGroup Is this a group ?
*/
shareCalendar(state, { calendar, sharee, id, group }) {
let newSharee = {
displayname: sharee,
id,
shareCalendar(state, { calendar, user, displayName, uri, isGroup }) {
calendar = state.calendars.find(search => search.id === calendar.id)
const newSharee = {
displayName,
id: user,
writeable: false,
group
isGroup,
uri
}
calendar.shares.push(newSharee)
},
@ -327,34 +351,27 @@ const mutations = {
* Removes a sharee from calendar shares list
*
* @param {Object} state The store data
* @param {Object} sharee The sharee
* @param {Object} data Destructuring object
* @param {Calendar} data.calendar The calendar
* @param {String} data.uri The sharee uri
*/
removeSharee(state, sharee) {
let calendar = state.calendars.find(search => {
for (let i in search.shares) {
if (search.shares[i] === sharee) {
return true
}
}
})
calendar.shares.splice(calendar.shares.indexOf(sharee), 1)
removeSharee(state, { calendar, uri }) {
calendar = state.calendars.find(search => search.id === calendar.id)
let shareIndex = calendar.shares.findIndex(sharee => sharee.uri === uri)
calendar.shares.splice(shareIndex, 1)
},
/**
* Toggles sharee's writable permission
*
* @param {Object} state The store data
* @param {Object} sharee The sharee
* @param {Object} data Destructuring object
* @param {Object} data.calendar The calendar
* @param {String} data.uri The sharee uri
*/
updateShareeWritable(state, sharee) {
let calendar = state.calendars.find(search => {
for (let i in search.shares) {
if (search.shares[i] === sharee) {
return true
}
}
})
sharee = calendar.shares.find(search => search === sharee)
updateShareeWritable(state, { calendar, uri }) {
calendar = state.calendars.find(search => search.id === calendar.id)
let sharee = calendar.shares.find(sharee => sharee.uri === uri)
sharee.writeable = !sharee.writeable
}
}
@ -567,20 +584,36 @@ const actions = {
* Removes a sharee from a calendar
*
* @param {Object} context The store mutations Current context
* @param {Object} sharee Calendar sharee object
* @param {Object} data Destructuring object
* @param {Object} data.calendar The calendar
* @param {String} data.uri The sharee uri
*/
removeSharee(context, sharee) {
context.commit('removeSharee', sharee)
async removeSharee(context, { calendar, uri }) {
try {
await calendar.dav.unshare(uri)
context.commit('removeSharee', { calendar, uri })
} catch (error) {
throw error
}
},
/**
* Toggles permissions of calendar sharees writeable rights
*
* @param {Object} context The store mutations Current context
* @param {Object} sharee Calendar sharee object
* @param {Object} data Destructuring object
* @param {Object} data.calendar The calendar
* @param {String} data.uri The sharee uri
* @param {Boolean} data.writeable The sharee permission
*/
toggleShareeWritable(context, sharee) {
context.commit('updateShareeWritable', sharee)
async toggleShareeWritable(context, { calendar, uri, writeable }) {
try {
await calendar.dav.share(uri, writeable)
context.commit('updateShareeWritable', { calendar, uri, writeable })
} catch (error) {
throw error
}
},
/**
@ -588,13 +621,19 @@ const actions = {
*
* @param {Object} context The store mutations Current context
* @param {Calendar} data.calendar The calendar
* @param {String} data.sharee The sharee
* @param {Boolean} data.id The id
* @param {Boolean} data.group The group
* @param {String} data.user The userId
* @param {String} data.displayName The displayName
* @param {String} data.uri The sharing principalScheme uri
* @param {Boolean} data.isGroup Is this a group ?
*/
shareCalendar(context, { calendar, sharee, id, group }) {
// Share a calendar with the entered group or user
context.commit('shareCalendar', { calendar, sharee, id, group })
async shareCalendar(context, { calendar, user, displayName, uri, isGroup }) {
// Share calendar with entered group or user
try {
await calendar.dav.share(uri)
context.commit('shareCalendar', { calendar, user, displayName, uri, isGroup })
} catch (error) {
throw error
}
},
}