mirror of
https://github.com/nextcloud/contacts.git
synced 2024-09-12 21:00:36 +02:00
New member button and virtual list
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
parent
21c5e699ff
commit
77cc60e0ed
27 changed files with 1242 additions and 374 deletions
20
.eslintrc.js
20
.eslintrc.js
|
@ -1,8 +1,20 @@
|
|||
module.exports = {
|
||||
globals: {
|
||||
appVersion: true
|
||||
appVersion: true,
|
||||
},
|
||||
|
||||
plugins: ['import'],
|
||||
extends: ['@nextcloud'],
|
||||
|
||||
settings: {
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
paths: './tsconfig.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
extends: [
|
||||
'@nextcloud'
|
||||
]
|
||||
}
|
||||
|
|
|
@ -36,13 +36,14 @@
|
|||
"@mattkrick/sanitize-svg": "^0.3.1",
|
||||
"@nextcloud/auth": "^1.3.0",
|
||||
"@nextcloud/axios": "^1.6.0",
|
||||
"@nextcloud/capabilities": "^1.0.4",
|
||||
"@nextcloud/dialogs": "^3.1.2",
|
||||
"@nextcloud/event-bus": "^1.2.0",
|
||||
"@nextcloud/initial-state": "^1.2.0",
|
||||
"@nextcloud/l10n": "^1.4.1",
|
||||
"@nextcloud/moment": "^1.1.1",
|
||||
"@nextcloud/paths": "^1.1.2",
|
||||
"@nextcloud/router": "^1.2.0",
|
||||
"@nextcloud/router": "^2.0.0",
|
||||
"@nextcloud/vue": "^3.9.0",
|
||||
"axios": "^0.21.1",
|
||||
"b64-to-blob": "^1.2.19",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
</EmptyContent>
|
||||
|
||||
<EmptyContent v-else-if="circle.isPendingJoin" icon="icon-loading">
|
||||
{{ t('contacts', 'Joining circle') }}
|
||||
{{ t('contacts', 'Your request to join this circle is pending approval') }}
|
||||
</EmptyContent>
|
||||
|
||||
<EmptyContent v-else icon="icon-loading">
|
||||
|
@ -76,7 +76,8 @@ import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
|
|||
import CircleDetails from '../CircleDetails'
|
||||
import MemberList from '../MemberList'
|
||||
import RouterMixin from '../../mixins/RouterMixin'
|
||||
import { MEMBER_LEVEL_NONE } from '../../models/constants'
|
||||
import { joinCircle } from '../../services/circles.ts'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
|
||||
export default {
|
||||
name: 'CircleContent',
|
||||
|
@ -123,14 +124,6 @@ export default {
|
|||
isEmptyCircle() {
|
||||
return this.members.length === 0
|
||||
},
|
||||
|
||||
/**
|
||||
* Is the current user member of this circle?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isMemberOfCircle() {
|
||||
return this.circle.initiator?.level > MEMBER_LEVEL_NONE
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
@ -150,8 +143,17 @@ export default {
|
|||
/**
|
||||
* Request to join this circle
|
||||
*/
|
||||
requestJoin() {
|
||||
async requestJoin() {
|
||||
this.loadingJoin = true
|
||||
|
||||
try {
|
||||
await joinCircle(this.circle.id)
|
||||
} catch (error) {
|
||||
showError(t('contacts', 'Unable to join the circle'))
|
||||
} finally {
|
||||
this.loadingJoin = false
|
||||
}
|
||||
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
|
||||
<!-- copy circle link -->
|
||||
<ActionLink
|
||||
:href="circle.url"
|
||||
:href="circleUrl"
|
||||
:icon="copyLoading ? 'icon-loading-small' : 'icon-public'"
|
||||
@click.stop.prevent="copyToClipboard(circleUrl)">
|
||||
{{ copyButtonText }}
|
||||
|
@ -49,7 +49,7 @@
|
|||
|
||||
<!-- leave circle -->
|
||||
<ActionButton
|
||||
v-if="circle.isMember"
|
||||
v-if="circle.canLeave"
|
||||
@click="leaveCircle">
|
||||
{{ t('contacts', 'Leave circle') }}
|
||||
<ExitToApp slot="icon"
|
||||
|
@ -59,7 +59,7 @@
|
|||
|
||||
<!-- join circle -->
|
||||
<ActionButton
|
||||
v-else-if="circle.canJoin"
|
||||
v-else-if="!circle.isMember && circle.canJoin"
|
||||
@click="joinCircle">
|
||||
{{ joinButtonTitle }}
|
||||
<LocationEnter slot="icon"
|
||||
|
@ -93,9 +93,10 @@ import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
|
|||
import ExitToApp from 'vue-material-design-icons/ExitToApp'
|
||||
import LocationEnter from 'vue-material-design-icons/LocationEnter'
|
||||
|
||||
import CopyToClipboardMixin from '../../mixins/CopyToClipboardMixin'
|
||||
import { deleteCircle, joinCircle } from '../../services/circles'
|
||||
import { deleteCircle, joinCircle } from '../../services/circles.ts'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import Circle from '../../models/circle.ts'
|
||||
import CopyToClipboardMixin from '../../mixins/CopyToClipboardMixin'
|
||||
|
||||
export default {
|
||||
name: 'CircleNavigationItem',
|
||||
|
@ -114,7 +115,7 @@ export default {
|
|||
|
||||
props: {
|
||||
circle: {
|
||||
type: Object,
|
||||
type: Circle,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
@ -136,7 +137,8 @@ export default {
|
|||
},
|
||||
|
||||
circleUrl() {
|
||||
return window.location.origin + this.circle.url
|
||||
const route = this.$router.resolve(this.circle.router)
|
||||
return window.location.origin + route.href
|
||||
},
|
||||
|
||||
joinButtonTitle() {
|
||||
|
@ -147,21 +149,25 @@ export default {
|
|||
},
|
||||
|
||||
memberCount() {
|
||||
return this.circle?.members?.length || 0
|
||||
return Object.values(this.circle?.members || []).length
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Trigger the entity picker view
|
||||
addMemberToCircle() {
|
||||
async addMemberToCircle() {
|
||||
await this.$router.push(this.circle.router)
|
||||
emit('contacts:circles:append', this.circle.id)
|
||||
},
|
||||
|
||||
async joinCircle() {
|
||||
this.loading = true
|
||||
try {
|
||||
await joinCircle(this.circle.id)
|
||||
} catch (error) {
|
||||
showError(t('contacts', 'Unable to join the circle'))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
},
|
||||
|
|
|
@ -144,7 +144,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, GROUP_RECENTLY_CONTACTED, ELLIPSIS_COUNT } from '../../models/constants'
|
||||
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, GROUP_RECENTLY_CONTACTED, ELLIPSIS_COUNT } from '../../models/constants.ts'
|
||||
|
||||
import ActionInput from '@nextcloud/vue/dist/Components/ActionInput'
|
||||
import ActionText from '@nextcloud/vue/dist/Components/ActionText'
|
||||
|
@ -348,14 +348,14 @@ export default {
|
|||
|
||||
this.createCircleError = null
|
||||
|
||||
const circleId = await this.$store.dispatch('createCircle', circleName)
|
||||
const circle = await this.$store.dispatch('createCircle', circleName)
|
||||
this.isNewCircleMenuOpen = false
|
||||
|
||||
// Select group
|
||||
this.$router.push({
|
||||
name: 'circle',
|
||||
params: {
|
||||
selectedCircle: circleId,
|
||||
selectedCircle: circle.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
@ -368,12 +368,12 @@ export default {
|
|||
#newcircle {
|
||||
margin-top: 22px;
|
||||
|
||||
/deep/ a {
|
||||
::v-deep a {
|
||||
color: var(--color-text-maxcontrast)
|
||||
}
|
||||
}
|
||||
|
||||
.app-navigation__collapse /deep/ a {
|
||||
.app-navigation__collapse ::v-deep a {
|
||||
color: var(--color-text-maxcontrast)
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -90,7 +90,7 @@ import { encodePath } from '@nextcloud/paths'
|
|||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { generateRemoteUrl } from '@nextcloud/router'
|
||||
import { getFilePickerBuilder } from '@nextcloud/dialogs'
|
||||
import axios from 'axios'
|
||||
import axios from '@nextcloud/axios'
|
||||
|
||||
const CancelToken = axios.CancelToken
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
<!-- contacts picker -->
|
||||
<EntityPicker v-else-if="showPicker"
|
||||
:confirm-label="t('contacts', 'Add to group {group}', { group: pickerforGroup.name})"
|
||||
:confirm-label="t('contacts', 'Add to {group}', { group: pickerforGroup.name})"
|
||||
:data-types="pickerTypes"
|
||||
:data-set="pickerData"
|
||||
@close="onContactPickerClose"
|
||||
|
|
|
@ -109,4 +109,5 @@ export default {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -35,17 +35,17 @@
|
|||
:placeholder="t('contacts', 'Search {types}', {types: searchPlaceholderTypes})"
|
||||
class="entity-picker__search-input"
|
||||
type="search"
|
||||
@change="onSearch">
|
||||
@input="onSearch">
|
||||
</div>
|
||||
|
||||
<!-- Picked entities -->
|
||||
<transition-group
|
||||
v-if="Object.keys(selection).length > 0"
|
||||
v-if="Object.keys(selectionSet).length > 0"
|
||||
name="zoom"
|
||||
tag="ul"
|
||||
class="entity-picker__selection">
|
||||
<EntityBubble
|
||||
v-for="entity in selection"
|
||||
v-for="entity in selectionSet"
|
||||
:key="entity.key || `entity-${entity.type}-${entity.id}`"
|
||||
v-bind="entity"
|
||||
@delete="onDelete(entity)" />
|
||||
|
@ -68,7 +68,7 @@
|
|||
:data-sources="availableEntities"
|
||||
:data-component="EntitySearchResult"
|
||||
:estimate-size="44"
|
||||
:extra-props="{selection, onClick: onPick}" />
|
||||
:extra-props="{selection: selectionSet, onClick: onPick}" />
|
||||
|
||||
<EmptyContent v-else-if="searchQuery" icon="icon-search">
|
||||
{{ t('contacts', 'No results') }}
|
||||
|
@ -92,9 +92,10 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from '@nextcloud/vue/dist/Components/Modal'
|
||||
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
|
||||
import debounce from 'debounce'
|
||||
import VirtualList from 'vue-virtual-scroll-list'
|
||||
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
|
||||
import Modal from '@nextcloud/vue/dist/Components/Modal'
|
||||
|
||||
import EntityBubble from './EntityBubble'
|
||||
import EntitySearchResult from './EntitySearchResult'
|
||||
|
@ -138,6 +139,14 @@ export default {
|
|||
dataSet: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: data => {
|
||||
data.forEach(source => {
|
||||
if (!source.id || !source.label) {
|
||||
console.error('The following source MUST have a proper id and label key', source)
|
||||
}
|
||||
})
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -155,17 +164,44 @@ export default {
|
|||
type: String,
|
||||
default: t('contacts', 'Add to group'),
|
||||
},
|
||||
|
||||
/**
|
||||
* Override the local management of selection
|
||||
* You MUST use a sync modifier or the selection will be locked
|
||||
*/
|
||||
selection: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
selection: {},
|
||||
localSelection: {},
|
||||
EntitySearchResult,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* If the selection is set externally, let's use it
|
||||
*/
|
||||
selectionSet: {
|
||||
get() {
|
||||
if (this.selection !== null) {
|
||||
return this.selection
|
||||
}
|
||||
return this.localSelection
|
||||
},
|
||||
set(selection) {
|
||||
if (this.selection !== null) {
|
||||
this.$emit('update:selection', selection)
|
||||
}
|
||||
this.localSelection = selection
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Are we handling a single entity type ?
|
||||
* @returns {boolean}
|
||||
|
@ -179,7 +215,7 @@ export default {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
isEmptySelection() {
|
||||
return Object.keys(this.selection).length === 0
|
||||
return Object.keys(this.selectionSet).length === 0
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -219,14 +255,24 @@ export default {
|
|||
}
|
||||
|
||||
// Else group by types
|
||||
return this.dataTypes.map(type => [
|
||||
{
|
||||
id: type.id,
|
||||
label: type.label,
|
||||
heading: true,
|
||||
},
|
||||
...this.searchSet.filter(entity => entity.type === type.id),
|
||||
]).flat()
|
||||
return this.dataTypes.map(type => {
|
||||
const dataSet = this.searchSet.filter(entity => entity.type === type.id)
|
||||
const dataList = [
|
||||
{
|
||||
id: type.id,
|
||||
label: type.label,
|
||||
heading: true,
|
||||
},
|
||||
...dataSet,
|
||||
]
|
||||
|
||||
// If no results, hide the type
|
||||
if (dataSet.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return dataList
|
||||
}).flat()
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -249,23 +295,23 @@ export default {
|
|||
* Emitted when user submit the form
|
||||
* @type {Array} the selected entities
|
||||
*/
|
||||
this.$emit('submit', Object.values(this.selection))
|
||||
this.$emit('submit', Object.values(this.selectionSet))
|
||||
},
|
||||
|
||||
onSearch(event) {
|
||||
onSearch: debounce(function() {
|
||||
/**
|
||||
* Emitted when search change
|
||||
* @type {string} the search query
|
||||
*/
|
||||
this.$emit('search', this.searchQuery)
|
||||
},
|
||||
}, 200),
|
||||
|
||||
/**
|
||||
* Remove entity from selection
|
||||
* @param {Object} entity the entity to remove
|
||||
*/
|
||||
onDelete(entity) {
|
||||
this.$delete(this.selection, entity.id, entity)
|
||||
this.$delete(this.selectionSet, entity.id, entity)
|
||||
console.debug('Removing entity from selection', entity)
|
||||
},
|
||||
|
||||
|
@ -274,7 +320,7 @@ export default {
|
|||
* @param {Object} entity the entity to add
|
||||
*/
|
||||
onPick(entity) {
|
||||
this.$set(this.selection, entity.id, entity)
|
||||
this.$set(this.selectionSet, entity.id, entity)
|
||||
console.debug('Added entity to selection', entity)
|
||||
},
|
||||
|
||||
|
@ -283,7 +329,7 @@ export default {
|
|||
* @param {Object} entity the entity to add/remove
|
||||
*/
|
||||
onToggle(entity) {
|
||||
if (entity.id in this.selection) {
|
||||
if (entity.id in this.selectionSet) {
|
||||
this.onDelete(entity)
|
||||
} else {
|
||||
this.onPick(entity)
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
class="entity-picker__bubble"
|
||||
:class="{'entity-picker__bubble--selected': isSelected}"
|
||||
:display-name="source.label"
|
||||
:user="source.user"
|
||||
:margin="6"
|
||||
:size="44"
|
||||
url="#"
|
||||
|
@ -145,6 +146,11 @@ $icon-margin: ($clickable-area - $icon-size) / 2;
|
|||
&, * {
|
||||
// the whole row is clickable,let's force the proper cursor
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
-khtml-user-drag: none;
|
||||
-moz-user-drag: none;
|
||||
-o-user-drag: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,24 +21,58 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<VirtualList class="member-list app-content-list"
|
||||
data-key="id"
|
||||
:data-sources="list"
|
||||
:data-component="MemberListItem"
|
||||
:estimate-size="68"
|
||||
item-class="member-list__item" />
|
||||
<AppContentList>
|
||||
<div class="members-list__new">
|
||||
<button class="icon-add" @click="onShowPicker(circle.id)">
|
||||
{{ t('contacts', 'Add members') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<VirtualList class="members-list"
|
||||
data-key="id"
|
||||
:data-sources="list"
|
||||
:data-component="MembersListItem"
|
||||
:estimate-size="68" />
|
||||
|
||||
<!-- member picker -->
|
||||
<EntityPicker v-if="showPicker"
|
||||
:confirm-label="t('contacts', 'Add to {circle}', { circle: circle.displayName})"
|
||||
:data-types="pickerTypes"
|
||||
:data-set="pickerData"
|
||||
:loading="pickerLoading"
|
||||
:selection.sync="pickerSelection"
|
||||
@close="resetPicker"
|
||||
@search="onSearch"
|
||||
@submit="onPickerPick" />
|
||||
</AppContentList>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MemberListItem from './MemberList/MemberListItem'
|
||||
import AppContentList from '@nextcloud/vue/dist/Components/AppContentList'
|
||||
import Actions from '@nextcloud/vue/dist/Components/Actions'
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import VirtualList from 'vue-virtual-scroll-list'
|
||||
|
||||
import MembersListItem from './MembersList/MembersListItem'
|
||||
import EntityPicker from './EntityPicker/EntityPicker'
|
||||
import RouterMixin from '../mixins/RouterMixin'
|
||||
|
||||
import { getRecommendations, getSuggestions } from '../services/collaborationAutocompletion'
|
||||
import { showError, showWarning } from '@nextcloud/dialogs'
|
||||
import { subscribe } from '@nextcloud/event-bus'
|
||||
import { SHARES_TYPES_MEMBER_MAP } from '../models/constants.ts'
|
||||
|
||||
export default {
|
||||
name: 'MemberList',
|
||||
|
||||
components: {
|
||||
Actions,
|
||||
ActionButton,
|
||||
AppContentList,
|
||||
VirtualList,
|
||||
EntityPicker,
|
||||
},
|
||||
mixins: [RouterMixin],
|
||||
|
||||
props: {
|
||||
list: {
|
||||
|
@ -49,20 +83,165 @@ export default {
|
|||
|
||||
data() {
|
||||
return {
|
||||
MemberListItem,
|
||||
MembersListItem,
|
||||
pickerLoading: false,
|
||||
showPicker: false,
|
||||
|
||||
recommendations: [],
|
||||
pickerCircle: null,
|
||||
pickerData: [],
|
||||
pickerSelection: {},
|
||||
pickerTypes: [{
|
||||
id: `picker-${OC.Share.SHARE_TYPE_USER}`,
|
||||
label: t('contacts', 'Users'),
|
||||
}, {
|
||||
id: `picker-${OC.Share.SHARE_TYPE_GROUP}`,
|
||||
label: t('contacts', 'Groups'),
|
||||
}, {
|
||||
id: `picker-${OC.Share.SHARE_TYPE_CIRCLE}`,
|
||||
label: t('contacts', 'Circles'),
|
||||
}, {
|
||||
id: `picker-${OC.Share.SHARE_TYPE_EMAIL}`,
|
||||
label: t('contacts', 'Email'),
|
||||
}],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Return the current circle
|
||||
* @returns {Circle}
|
||||
*/
|
||||
circle() {
|
||||
return this.$store.getters.getCircle(this.selectedCircle)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
mounted() {
|
||||
subscribe('contacts:circles:append', this.onShowPicker)
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Show picker and fetch for recommendations
|
||||
* Cache the circleId in case the url change or something
|
||||
* and make sure we add them to the desired circle.
|
||||
* @param {string} circleId the circle id to add members to
|
||||
*/
|
||||
async onShowPicker(circleId) {
|
||||
this.showPicker = true
|
||||
this.pickerLoading = true
|
||||
this.pickerCircle = circleId
|
||||
|
||||
try {
|
||||
const results = await getRecommendations()
|
||||
// cache recommendations
|
||||
this.recommendations = results
|
||||
this.pickerData = results
|
||||
} catch (error) {
|
||||
console.error('Unable to get the recommendations list', error)
|
||||
// Do not show the error, let the user search
|
||||
// showError(t('contacts', 'Unable to get the recommendations list'))
|
||||
} finally {
|
||||
this.pickerLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* On EntityPicker search.
|
||||
* Returns recommendations if empty
|
||||
* @param {string} term the searched term
|
||||
*/
|
||||
async onSearch(term) {
|
||||
if (term.trim() === '') {
|
||||
this.pickerData = this.recommendations
|
||||
return
|
||||
}
|
||||
|
||||
this.pickerLoading = true
|
||||
|
||||
try {
|
||||
const results = await getSuggestions(term)
|
||||
this.pickerData = results
|
||||
} catch (error) {
|
||||
console.error('Unable to get the results', error)
|
||||
showError(t('contacts', 'Unable to get the results'))
|
||||
} finally {
|
||||
this.pickerLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* On picker submit
|
||||
* @param {Array} selection the selection to add to the circle
|
||||
*/
|
||||
async onPickerPick(selection) {
|
||||
console.info('Adding selection to circle', selection, this.pickerCircle)
|
||||
|
||||
this.pickerLoading = true
|
||||
|
||||
selection = selection.map(entry => ({
|
||||
id: entry.shareWith,
|
||||
type: SHARES_TYPES_MEMBER_MAP[entry.shareType],
|
||||
}))
|
||||
|
||||
try {
|
||||
const members = await this.$store.dispatch('addMembersToCircle', { circleId: this.pickerCircle, selection })
|
||||
|
||||
if (members.length !== selection.length) {
|
||||
showWarning(t('contacts', 'Some members could not be added'))
|
||||
// TODO filter successful members and edit selection
|
||||
this.selection = []
|
||||
return
|
||||
}
|
||||
|
||||
this.resetPicker()
|
||||
} catch (error) {
|
||||
showError(t('contacts', 'There was an issue adding members to the circle'))
|
||||
console.error('There was an issue adding members to the circle', this.pickerCircle, error)
|
||||
} finally {
|
||||
this.pickerLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset picker related variables
|
||||
*/
|
||||
resetPicker() {
|
||||
this.showPicker = false
|
||||
this.pickerCircle = null
|
||||
this.pickerData = []
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-content-list {
|
||||
flex: 1 1 300px;
|
||||
// Cancel scrolling
|
||||
overflow: visible;
|
||||
|
||||
.empty-content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.members-list {
|
||||
// Make virtual scroller scrollable
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
&__new {
|
||||
padding: 10px;
|
||||
|
||||
button {
|
||||
height: 44px;
|
||||
padding-left: 44px;
|
||||
background-position: 14px center;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -22,13 +22,14 @@
|
|||
|
||||
<template>
|
||||
<ListItemIcon
|
||||
:id="source.id"
|
||||
:key="source.id"
|
||||
:id="source.singleId"
|
||||
:key="source.singleId"
|
||||
:avatar-size="44"
|
||||
:is-no-user="!source.isUser"
|
||||
:subtitle="levelName"
|
||||
:title="source.displayName"
|
||||
:user="source.userId">
|
||||
:user="source.userId"
|
||||
class="members-list__item">
|
||||
<Actions @close="onMenuClose">
|
||||
<template v-if="loading">
|
||||
<ActionText icon="icon-loading-small">
|
||||
|
@ -36,31 +37,26 @@
|
|||
</ActionText>
|
||||
</template>
|
||||
|
||||
<!-- Level picker -->
|
||||
<template v-else-if="showLevelMenu">
|
||||
<ActionButton @click="toggleLevelMenu">
|
||||
{{ t('contacts', 'Back to the menu') }}
|
||||
<ArrowLeft slot="icon"
|
||||
:size="16"
|
||||
decorative />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
v-for="level in availableLevelsChange"
|
||||
:key="level"
|
||||
icon=""
|
||||
@click="changeLevel(level)">
|
||||
{{ CIRCLES_MEMBER_LEVELS[level] }}
|
||||
</ActionButton>
|
||||
</template>
|
||||
|
||||
<!-- Normal menu -->
|
||||
<template v-else>
|
||||
<ActionButton v-if="canChangeLevel" @click="toggleLevelMenu">
|
||||
{{ t('contacts', 'Change level') }}
|
||||
<ShieldCheck slot="icon"
|
||||
:size="16"
|
||||
decorative />
|
||||
</ActionButton>
|
||||
<!-- Level picker -->
|
||||
<template v-if="canChangeLevel">
|
||||
<ActionText>
|
||||
{{ t('contacts', 'Manage level') }}
|
||||
<ShieldCheck slot="icon"
|
||||
:size="16"
|
||||
decorative />
|
||||
</ActionText>
|
||||
<ActionButton
|
||||
v-for="level in availableLevelsChange"
|
||||
:key="level"
|
||||
icon=""
|
||||
@click="changeLevel(level)">
|
||||
{{ levelChangeLabel(level) }}
|
||||
</ActionButton>
|
||||
|
||||
<ActionSeparator />
|
||||
</template>
|
||||
|
||||
<!-- Leave or delete member from circle -->
|
||||
<ActionButton v-if="isCurrentUser && !circle.isOwner" @click="deleteMember">
|
||||
|
@ -78,30 +74,30 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { MEMBER_LEVEL_MEMBER, CIRCLES_MEMBER_LEVELS } from '../../models/constants'
|
||||
import { CIRCLES_MEMBER_LEVELS, MemberLevels } from '../../models/constants.ts'
|
||||
|
||||
import Actions from '@nextcloud/vue/dist/Components/Actions'
|
||||
import ListItemIcon from '@nextcloud/vue/dist/Components/ListItemIcon'
|
||||
import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator'
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import ActionText from '@nextcloud/vue/dist/Components/ActionText'
|
||||
|
||||
import ArrowLeft from 'vue-material-design-icons/ArrowLeft'
|
||||
import ExitToApp from 'vue-material-design-icons/ExitToApp'
|
||||
import ShieldCheck from 'vue-material-design-icons/ShieldCheck'
|
||||
|
||||
import { changeMemberLevel } from '../../services/circles'
|
||||
import { changeMemberLevel } from '../../services/circles.ts'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import Member from '../../models/member'
|
||||
import Member from '../../models/member.ts'
|
||||
import RouterMixin from '../../mixins/RouterMixin'
|
||||
|
||||
export default {
|
||||
name: 'MemberListItem',
|
||||
name: 'MembersListItem',
|
||||
|
||||
components: {
|
||||
Actions,
|
||||
ActionButton,
|
||||
ActionSeparator,
|
||||
ActionText,
|
||||
ArrowLeft,
|
||||
ExitToApp,
|
||||
ListItemIcon,
|
||||
ShieldCheck,
|
||||
|
@ -120,7 +116,6 @@ export default {
|
|||
CIRCLES_MEMBER_LEVELS,
|
||||
|
||||
loading: false,
|
||||
showLevelMenu: false,
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -146,7 +141,7 @@ export default {
|
|||
*/
|
||||
levelName() {
|
||||
return CIRCLES_MEMBER_LEVELS[this.source.level]
|
||||
|| CIRCLES_MEMBER_LEVELS[MEMBER_LEVEL_MEMBER]
|
||||
|| CIRCLES_MEMBER_LEVELS[MemberLevels.MEMBER]
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -154,7 +149,7 @@ export default {
|
|||
* @returns {number}
|
||||
*/
|
||||
currentUserLevel() {
|
||||
return this.circle?.initiator?.level || MEMBER_LEVEL_MEMBER
|
||||
return this.circle?.initiator?.level || MemberLevels.MEMBER
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -162,7 +157,7 @@ export default {
|
|||
* @returns {string}
|
||||
*/
|
||||
currentUserId() {
|
||||
return this.circle?.initiator?.id
|
||||
return this.circle?.initiator?.singleId
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -170,7 +165,13 @@ export default {
|
|||
* @returns {Array}
|
||||
*/
|
||||
availableLevelsChange() {
|
||||
return Object.keys(CIRCLES_MEMBER_LEVELS).filter(level => level < this.currentUserLevel)
|
||||
return Object.keys(CIRCLES_MEMBER_LEVELS)
|
||||
// Object.keys returns those as string
|
||||
.map(level => parseInt(level, 10))
|
||||
// we cannot set to a level higher than the current user's level
|
||||
.filter(level => level < this.currentUserLevel)
|
||||
// we cannot set to the level this member is already
|
||||
.filter(level => level !== this.source.level)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -178,7 +179,7 @@ export default {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
isCurrentUser() {
|
||||
return this.currentUserId === this.source.id
|
||||
return this.currentUserId === this.source.singleId
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -188,9 +189,11 @@ export default {
|
|||
canChangeLevel() {
|
||||
// we can change if the member is at the same
|
||||
// or lower level as the current user
|
||||
// BUT not an owner as there can/must always be one
|
||||
return this.availableLevelsChange.length > 0
|
||||
&& this.currentUserLevel >= this.source.level
|
||||
&& this.circle.canManageMembers
|
||||
&& !(this.circle.isOwner && this.isCurrentUser)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -198,13 +201,22 @@ export default {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
canDelete() {
|
||||
return this.currentUserLevel > MEMBER_LEVEL_MEMBER
|
||||
return this.currentUserLevel > MemberLevels.MEMBER
|
||||
&& this.source.level <= this.currentUserLevel
|
||||
&& !this.isCurrentUser
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleLevelMenu() {
|
||||
this.showLevelMenu = !this.showLevelMenu
|
||||
/**
|
||||
* Return the promote/demote member action label
|
||||
* @param {MemberLevel} level the member level
|
||||
* @returns {string}
|
||||
*/
|
||||
levelChangeLabel(level) {
|
||||
if (this.source.level < level) {
|
||||
return t('contacts', 'Promote to {level}', { level: CIRCLES_MEMBER_LEVELS[level] })
|
||||
}
|
||||
return t('contacts', 'Demote to {level}', { level: CIRCLES_MEMBER_LEVELS[level] })
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -219,6 +231,10 @@ export default {
|
|||
leave: this.isCurrentUser,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.response.status === 404) {
|
||||
console.debug('Member is not in circle')
|
||||
return
|
||||
}
|
||||
console.error('Could not delete the member', this.source, error)
|
||||
showError(t('contacts', 'Could not delete the member {displayName}', this.source))
|
||||
} finally {
|
||||
|
@ -232,6 +248,10 @@ export default {
|
|||
try {
|
||||
await changeMemberLevel(this.circle.id, this.source.id, level)
|
||||
this.showLevelMenu = false
|
||||
|
||||
// this.source is a class. We're modifying the class setter, not the prop itself
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.source.level = level
|
||||
} catch (error) {
|
||||
console.error('Could not change the member level to', CIRCLES_MEMBER_LEVELS[level])
|
||||
showError(t('contacts', 'Could not change the member level to {level}', {
|
||||
|
@ -252,7 +272,12 @@ export default {
|
|||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.member-list__item {
|
||||
.members-list__item {
|
||||
padding: 8px;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
142
src/models/circle.d.ts
vendored
Normal file
142
src/models/circle.d.ts
vendored
Normal file
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
import Member from './member';
|
||||
declare type MemberList = Record<string, Member>;
|
||||
export default class Circle {
|
||||
_data: any;
|
||||
_members: MemberList;
|
||||
_owner: Member;
|
||||
_initiator: Member;
|
||||
/**
|
||||
* Creates an instance of Circle
|
||||
*/
|
||||
constructor(data: Object);
|
||||
/**
|
||||
* Update inner circle data, owner and initiator
|
||||
*/
|
||||
updateData(data: any): void;
|
||||
/**
|
||||
* Circle id
|
||||
*/
|
||||
get id(): string;
|
||||
/**
|
||||
* Formatted display name
|
||||
*/
|
||||
get displayName(): string;
|
||||
/**
|
||||
* Circle creation date
|
||||
*/
|
||||
get creation(): number;
|
||||
/**
|
||||
* Circle description
|
||||
*/
|
||||
get description(): string;
|
||||
/**
|
||||
* Circle description
|
||||
*/
|
||||
set description(text: string);
|
||||
/**
|
||||
* Circle ini_initiator the current
|
||||
* user info for this circle
|
||||
*/
|
||||
get initiator(): Member;
|
||||
/**
|
||||
* Circle ownership
|
||||
*/
|
||||
get owner(): Member;
|
||||
/**
|
||||
* Set new circle owner
|
||||
*/
|
||||
set owner(owner: Member);
|
||||
/**
|
||||
* Circle members
|
||||
*/
|
||||
get members(): MemberList;
|
||||
/**
|
||||
* Define members circle
|
||||
*/
|
||||
set members(members: MemberList);
|
||||
/**
|
||||
* Add a member to this circle
|
||||
*/
|
||||
addMember(member: Member): void;
|
||||
/**
|
||||
* Remove a member from this circle
|
||||
*/
|
||||
deleteMember(member: Member): void;
|
||||
get settings(): any;
|
||||
/**
|
||||
* Circle config
|
||||
*/
|
||||
get config(): any;
|
||||
/**
|
||||
* Circle requires invite to be confirmed by moderator or above
|
||||
*/
|
||||
get requireJoinAccept(): boolean;
|
||||
/**
|
||||
* Circle can be requested to join
|
||||
*/
|
||||
get canJoin(): boolean;
|
||||
/**
|
||||
* Circle is visible to others
|
||||
*/
|
||||
get isVisible(): boolean;
|
||||
/**
|
||||
* Circle requires invite to be accepted by the member
|
||||
*/
|
||||
get requireInviteAccept(): boolean;
|
||||
/**
|
||||
* Can the initiator add members to this circle?
|
||||
*/
|
||||
get isOwner(): boolean;
|
||||
/**
|
||||
* Is the initiator a member of this circle?
|
||||
*/
|
||||
get isMember(): boolean;
|
||||
/**
|
||||
* Can the initiator delete this circle?
|
||||
*/
|
||||
get canDelete(): boolean;
|
||||
/**
|
||||
* Can the initiator leave this circle?
|
||||
*/
|
||||
get canLeave(): boolean;
|
||||
/**
|
||||
* Can the initiator add/remove members to this circle?
|
||||
*/
|
||||
get canManageMembers(): boolean;
|
||||
/**
|
||||
* Vue router param
|
||||
*/
|
||||
get router(): {
|
||||
name: string;
|
||||
params: {
|
||||
selectedCircle: string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Default javascript fallback
|
||||
* Used for sorting as well
|
||||
*/
|
||||
toString(): string;
|
||||
}
|
||||
export {};
|
|
@ -20,38 +20,31 @@
|
|||
*
|
||||
*/
|
||||
|
||||
/** @typedef { import('./member') } Member */
|
||||
|
||||
import {
|
||||
MEMBER_LEVEL_MODERATOR, MEMBER_LEVEL_NONE, MEMBER_LEVEL_OWNER,
|
||||
CIRCLE_CONFIG_REQUEST, CIRCLE_CONFIG_INVITE, CIRCLE_CONFIG_OPEN, CIRCLE_CONFIG_VISIBLE,
|
||||
} from './constants'
|
||||
|
||||
import Vue from 'vue'
|
||||
import Member from './member'
|
||||
|
||||
import { CircleConfigs, MemberLevels } from './constants'
|
||||
|
||||
type MemberList = Record<string, Member>
|
||||
|
||||
export default class Circle {
|
||||
|
||||
_data = {}
|
||||
_members = {}
|
||||
_data: any = {}
|
||||
_members: MemberList = {}
|
||||
_owner: Member
|
||||
_initiator: Member
|
||||
|
||||
/**
|
||||
* Creates an instance of Circle
|
||||
*
|
||||
* @param {Object} data the vcard data as string with proper new lines
|
||||
* @param {object} circle the addressbook which the contat belongs to
|
||||
* @memberof Circle
|
||||
*/
|
||||
constructor(data) {
|
||||
constructor(data: Object) {
|
||||
this.updateData(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update inner circle data, owner and initiator
|
||||
* @param {Object} data the vcard data as string with proper new lines
|
||||
* @memberof Circle
|
||||
*/
|
||||
updateData(data) {
|
||||
updateData(data: any) {
|
||||
if (typeof data !== 'object') {
|
||||
throw new Error('Invalid circle')
|
||||
}
|
||||
|
@ -62,145 +55,119 @@ export default class Circle {
|
|||
}
|
||||
|
||||
this._data = data
|
||||
this._data.initiator = new Member(data.initiator, this)
|
||||
this._data.owner = new Member(data.owner)
|
||||
this._owner = new Member(data.owner, this)
|
||||
|
||||
if (data.initiator) {
|
||||
this._initiator = new Member(data.initiator, this)
|
||||
}
|
||||
}
|
||||
|
||||
// METADATA -----------------------------------------
|
||||
/**
|
||||
* Circle id
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {string}
|
||||
*/
|
||||
get id() {
|
||||
get id(): string {
|
||||
return this._data.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted display name
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {string}
|
||||
*/
|
||||
get displayName() {
|
||||
get displayName(): string {
|
||||
return this._data.displayName
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle creation date
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {number}
|
||||
*/
|
||||
get creation() {
|
||||
get creation(): number {
|
||||
return this._data.creation
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle description
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {string}
|
||||
*/
|
||||
get description() {
|
||||
get description(): string {
|
||||
return this._data.description
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle description
|
||||
* @param {string} text circle description
|
||||
* @memberof Circle
|
||||
*/
|
||||
set description(text) {
|
||||
set description(text: string) {
|
||||
this._data.description = text
|
||||
}
|
||||
|
||||
// MEMBERSHIP -----------------------------------------
|
||||
/**
|
||||
* Circle initiator. This is the current
|
||||
* Circle ini_initiator the current
|
||||
* user info for this circle
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {Member}
|
||||
*/
|
||||
get initiator() {
|
||||
return this._data.initiator
|
||||
get initiator(): Member {
|
||||
return this._initiator
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle ownership
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {Member}
|
||||
*/
|
||||
get owner() {
|
||||
return this._data.owner
|
||||
get owner(): Member {
|
||||
return this._owner
|
||||
}
|
||||
|
||||
/**
|
||||
* Set new circle owner
|
||||
* @param {Member} owner circle owner
|
||||
* @memberof Circle
|
||||
*/
|
||||
set owner(owner) {
|
||||
set owner(owner: Member) {
|
||||
if (owner.constructor.name !== Member.name) {
|
||||
throw new Error('Owner must be a Member type')
|
||||
}
|
||||
this._data.owner = owner
|
||||
this._owner = owner
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle members
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {Member[]}
|
||||
*/
|
||||
get members() {
|
||||
get members(): MemberList {
|
||||
return this._members
|
||||
}
|
||||
|
||||
/**
|
||||
* Define members circle
|
||||
* @param {Member[]} members the members list
|
||||
* @memberof Circle
|
||||
*/
|
||||
set members(members) {
|
||||
set members(members: MemberList) {
|
||||
this._members = members
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to this circle
|
||||
* @param {Member} member the member to add
|
||||
*/
|
||||
addMember(member) {
|
||||
addMember(member: Member) {
|
||||
if (member.constructor.name !== Member.name) {
|
||||
throw new Error('Member must be a Member type')
|
||||
}
|
||||
|
||||
const uid = member.id
|
||||
if (this._members[uid]) {
|
||||
console.warn('Duplicate member overrided', this._members[uid], member)
|
||||
const singleId = member.singleId
|
||||
if (this._members[singleId]) {
|
||||
console.warn('Ignoring duplicate member', member)
|
||||
}
|
||||
Vue.set(this._members, uid, member)
|
||||
Vue.set(this._members, singleId, member)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from this circle
|
||||
* @param {Member} member the member to delete
|
||||
*/
|
||||
deleteMember(member) {
|
||||
deleteMember(member: Member) {
|
||||
if (member.constructor.name !== Member.name) {
|
||||
throw new Error('Member must be a Member type')
|
||||
}
|
||||
|
||||
const uid = member.id
|
||||
if (!this._members[uid]) {
|
||||
const singleId = member.singleId
|
||||
if (!this._members[singleId]) {
|
||||
console.warn('The member was not in this circle. Nothing was done.', member)
|
||||
}
|
||||
|
||||
// Delete and clear memory
|
||||
Vue.delete(this._members, uid)
|
||||
Vue.delete(this._members, singleId)
|
||||
}
|
||||
|
||||
// CONFIGS --------------------------------------------
|
||||
|
@ -210,9 +177,6 @@ export default class Circle {
|
|||
|
||||
/**
|
||||
* Circle config
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {number}
|
||||
*/
|
||||
get config() {
|
||||
return this._data.config
|
||||
|
@ -220,91 +184,71 @@ export default class Circle {
|
|||
|
||||
/**
|
||||
* Circle requires invite to be confirmed by moderator or above
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get requireJoinAccept() {
|
||||
return (this._data.config & CIRCLE_CONFIG_REQUEST) !== 0
|
||||
return (this._data.config & CircleConfigs.VISIBLE) !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle can be requested to join
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canJoin() {
|
||||
return (this._data.config & CIRCLE_CONFIG_OPEN) !== 0
|
||||
return (this._data.config & CircleConfigs.OPEN) !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle is visible to others
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isVisible() {
|
||||