summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJohn Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>2021-03-15 11:48:15 +0100
committerJohn Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>2021-05-30 10:28:56 +0200
commit77cc60e0edd627d3bbed63f0e34b13822b387baf (patch)
treecb8c26f64f3fb88175dd8ad90d1347865321dfb6
parent21c5e699ffa394c45094e898af0f5192cf239bee (diff)
New member button and virtual list
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
-rw-r--r--.eslintrc.js20
-rw-r--r--package.json3
-rw-r--r--src/components/AppContent/CircleContent.vue24
-rw-r--r--src/components/AppNavigation/CircleNavigationItem.vue24
-rw-r--r--src/components/AppNavigation/RootNavigation.vue10
-rw-r--r--src/components/AppNavigation/Settings/SettingsImportContacts.vue2
-rw-r--r--src/components/EntityPicker/ContactsPicker.vue2
-rw-r--r--src/components/EntityPicker/EntityBubble.vue1
-rw-r--r--src/components/EntityPicker/EntityPicker.vue90
-rw-r--r--src/components/EntityPicker/EntitySearchResult.vue6
-rw-r--r--src/components/MemberList.vue197
-rw-r--r--src/components/MembersList/MembersListItem.vue (renamed from src/components/MemberList/MemberListItem.vue)109
-rw-r--r--src/models/circle.d.ts142
-rw-r--r--src/models/circle.ts (renamed from src/models/circle.js)162
-rw-r--r--src/models/constants.d.ts68
-rw-r--r--src/models/constants.js75
-rw-r--r--src/models/constants.ts133
-rw-r--r--src/models/member.d.ts76
-rw-r--r--src/models/member.ts (renamed from src/models/member.js)69
-rw-r--r--src/router/index.js10
-rw-r--r--src/services/circles.d.ts101
-rw-r--r--src/services/circles.ts (renamed from src/services/circles.js)62
-rw-r--r--src/services/collaborationAutocompletion.js141
-rw-r--r--src/store/circles.js44
-rw-r--r--src/views/Contacts.vue21
-rw-r--r--tsconfig.json14
-rw-r--r--webpack.js8
27 files changed, 1241 insertions, 373 deletions
diff --git a/.eslintrc.js b/.eslintrc.js
index 307f668a..a532df9e 100644
--- a/.eslintrc.js
+++ b/.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'
- ]
}
diff --git a/package.json b/package.json
index db5467c3..d96d2dae 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/AppContent/CircleContent.vue b/src/components/AppContent/CircleContent.vue
index d382ae16..164aaf5b 100644
--- a/src/components/AppContent/CircleContent.vue
+++ b/src/components/AppContent/CircleContent.vue
@@ -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
+ }
+
},
},
}
diff --git a/src/components/AppNavigation/CircleNavigationItem.vue b/src/components/AppNavigation/CircleNavigationItem.vue
index 32846d5f..26838678 100644
--- a/src/components/AppNavigation/CircleNavigationItem.vue
+++ b/src/components/AppNavigation/CircleNavigationItem.vue
@@ -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
}
},
diff --git a/src/components/AppNavigation/RootNavigation.vue b/src/components/AppNavigation/RootNavigation.vue
index e80270f4..675f5750 100644
--- a/src/components/AppNavigation/RootNavigation.vue
+++ b/src/components/AppNavigation/RootNavigation.vue
@@ -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>
diff --git a/src/components/AppNavigation/Settings/SettingsImportContacts.vue b/src/components/AppNavigation/Settings/SettingsImportContacts.vue
index 10ffa9f8..4d6d309d 100644
--- a/src/components/AppNavigation/Settings/SettingsImportContacts.vue
+++ b/src/components/AppNavigation/Settings/SettingsImportContacts.vue
@@ -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
diff --git a/src/components/EntityPicker/ContactsPicker.vue b/src/components/EntityPicker/ContactsPicker.vue
index 99e50b25..f388298c 100644
--- a/src/components/EntityPicker/ContactsPicker.vue
+++ b/src/components/EntityPicker/ContactsPicker.vue
@@ -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"
diff --git a/src/components/EntityPicker/EntityBubble.vue b/src/components/EntityPicker/EntityBubble.vue
index e76596e1..fbcc1ba2 100644
--- a/src/components/EntityPicker/EntityBubble.vue
+++ b/src/components/EntityPicker/EntityBubble.vue
@@ -109,4 +109,5 @@ export default {
}
}
}
+
</style>
diff --git a/src/components/EntityPicker/EntityPicker.vue b/src/components/EntityPicker/EntityPicker.vue
index 8fb25f23..878d9fb7 100644
--- a/src/components/EntityPicker/EntityPicker.vue
+++ b/src/components/EntityPicker/EntityPicker.vue
@@ -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,18 +164,45 @@ 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)
diff --git a/src/components/EntityPicker/EntitySearchResult.vue b/src/components/EntityPicker/EntitySearchResult.vue
index f11b371b..1def9bb4 100644
--- a/src/components/EntityPicker/EntitySearchResult.vue
+++ b/src/components/EntityPicker/EntitySearchResult.vue
@@ -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;
}
}
diff --git a/src/components/MemberList.vue b/src/components/MemberList.vue
index 9d69b61a..63bedd84 100644
--- a/src/components/MemberList.vue
+++ b/src/components/MemberList.vue
@@ -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>
diff --git a/src/components/MemberList/MemberListItem.vue b/src/components/MembersList/MembersListItem.vue
index f47495f6..19e67906 100644
--- a/src/components/MemberList/MemberListItem.vue
+++ b/