diff options
author | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2021-03-15 11:48:15 +0100 |
---|---|---|
committer | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2021-05-30 10:28:56 +0200 |
commit | 77cc60e0edd627d3bbed63f0e34b13822b387baf (patch) | |
tree | cb8c26f64f3fb88175dd8ad90d1347865321dfb6 | |
parent | 21c5e699ffa394c45094e898af0f5192cf239bee (diff) |
New member button and virtual list
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
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/ |