diff options
author | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2020-07-02 17:49:42 +0200 |
---|---|---|
committer | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2020-08-21 09:56:08 +0200 |
commit | f44028131344636e45c5158cc13ccbe4edd19097 (patch) | |
tree | 12023f8c4d62c6ba173e572f89e15c1565799446 /src | |
parent | 63b2aff43903d51fc382a7c6cb0019845363c183 (diff) |
Add PatchPlugin
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Diffstat (limited to 'src')
-rw-r--r-- | src/components/EntityPicker/EntityBubble.vue | 25 | ||||
-rw-r--r-- | src/components/EntityPicker/EntityPicker.vue | 215 | ||||
-rw-r--r-- | src/components/EntityPicker/EntitySearchResult.vue | 21 | ||||
-rw-r--r-- | src/components/ProcessingScreen.vue | 57 | ||||
-rw-r--r-- | src/services/appendContactToGroup.js | 41 | ||||
-rw-r--r-- | src/store/contacts.js | 2 | ||||
-rw-r--r-- | src/views/Contacts.vue | 119 |
7 files changed, 366 insertions, 114 deletions
diff --git a/src/components/EntityPicker/EntityBubble.vue b/src/components/EntityPicker/EntityBubble.vue index 0394d9c2..bcc7705f 100644 --- a/src/components/EntityPicker/EntityBubble.vue +++ b/src/components/EntityPicker/EntityBubble.vue @@ -92,17 +92,22 @@ export default { background-color: var(--color-primary-light); } -.entity-picker__bubble-delete { - display: block; - height: 100%; - // squeeze in the border radius - margin-right: -4px; - opacity: .7; +.entity-picker__bubble { + // Add space between bubbles + margin-right: 4px; - &:hover, - &:active, - &:focus { - opacity: 1; + &-delete { + display: block; + height: 100%; + // squeeze in the border radius + margin-right: -4px; + opacity: .7; + + &:hover, + &:active, + &:focus { + opacity: 1; + } } } diff --git a/src/components/EntityPicker/EntityPicker.vue b/src/components/EntityPicker/EntityPicker.vue index d0421af0..a60ee85e 100644 --- a/src/components/EntityPicker/EntityPicker.vue +++ b/src/components/EntityPicker/EntityPicker.vue @@ -33,44 +33,49 @@ v-model="searchQuery" class="entity-picker__search-input" type="search" - :placeholder="t('contacts', 'Search contacts')"> + :placeholder="t('contacts', 'Search {types}', {types: searchPlaceholderTypes})" + @change="onSearch"> </div> - <!-- Content --> - <div class="entity-picker__content"> - <!-- Picked entities --> - <transition-group - v-if="selection.length > 0" - name="zoom" - tag="ul" - class="entity-picker__selection"> - <EntityBubble - v-for="entity in selection" + <!-- Picked entities --> + <transition-group + v-if="Object.keys(selection).length > 0" + name="zoom" + tag="ul" + class="entity-picker__selection"> + <EntityBubble + v-for="entity in selection" + :key="entity.key || `entity-${entity.type}-${entity.id}`" + v-bind="entity" + @delete="onDelete(entity)" /> + </transition-group> + + <!-- TODO: find better wording/icon --> + <EmptyContent v-if="loading" icon=""> + {{ t('contacts', 'Loading …') }} + </EmptyContent> + + <!-- Searched & picked entities --> + <div v-else-if="searchSet.length > 0 && availableEntities.length > 0" class="entity-picker__options"> + <!-- For each type we show title + list --> + <div v-for="type in availableEntities" :key="type.id" class="entity-picker__option"> + <!-- Show content if we have something to show --> + <h4 v-if="!isSingleType && type.dataSet.length > 0" class="entity-picker__option-caption"> + {{ t('contacts', 'Add {type}', {type: type.label.toLowerCase()}) }} + </h4> + + <EntitySearchResult v-for="entity in type.dataSet" :key="entity.key || `entity-${entity.type}-${entity.id}`" + :selection="selection" v-bind="entity" - @delete="onDelete(entity)" /> - </transition-group> - - <!-- Searched & picked entities --> - <template v-if="searchSet.length > 0 && availableEntities.length > 0"> - <section v-for="type in availableEntities" :key="type.id" class="entity-picker__options"> - <h4 v-if="isSingleType" class="entity-picker__options-caption"> - {{ t('contacts', 'Add {type}', {type: type.label}) }} - </h4> - <EntitySearchResult v-for="entity in type.dataSet" - :key="entity.key || `entity-${entity.type}-${entity.id}`" - v-bind="entity" /> - </section> - </template> - <EmptyContent v-else-if="searchQuery" icon="icon-search"> - {{ t('contacts', 'No results') }} - </EmptyContent> - <!-- TODO: find better wording/icon --> - <EmptyContent v-else icon=""> - {{ t('contacts', 'Loading …') }} - </EmptyContent> + @click="onToggle(entity)" /> + </div> </div> + <EmptyContent v-else-if="searchQuery" icon="icon-search"> + {{ t('contacts', 'No results') }} + </EmptyContent> + <div class="entity-picker__navigation"> <button class="navigation__button-left" @@ -104,14 +109,39 @@ export default { }, props: { - dataSet: { + loading: { + type: Boolean, + default: false, + }, + + /** + * The types of data within dataSet + * Array of objects. id must match dataSet entity type + */ + dataTypes: { type: Array, required: true, + validator: types => { + const invalidTypes = types.filter(type => !type.id && !type.label) + if (invalidTypes.length > 0) { + console.error('The following types MUST have a proper id and label key', invalidTypes) + return false + } + return true + }, }, - dataTypes: { + + /** + * The data to be used + */ + dataSet: { type: Array, required: true, }, + + /** + * The sorting key for the dataSet + */ sort: { type: String, default: 'label', @@ -121,13 +151,7 @@ export default { data() { return { searchQuery: '', - selection: [ - { type: 'user', label: 'Test 4 Lorem ipsum is a very long user name', id: 'test4' }, - { type: 'user', label: 'Test 2', id: 'test2' }, - { type: 'user', label: 'Test 1 (AVHJ)', id: 'tes2' }, - { type: 'user', label: 'Test 278 975 869', id: 'tebst2' }, - { type: 'user', label: 'Administrator', id: 'tespot2' }, - ], + selection: {}, } }, @@ -140,6 +164,13 @@ export default { return !(this.dataTypes.length > 1) }, + searchPlaceholderTypes() { + const types = this.dataTypes + .map(type => type.label) + .join(', ') + return `${types}…` + }, + /** * Available data based on current search if query * is valid, returns default full data et otherwise @@ -172,27 +203,63 @@ export default { return this.dataTypes.map(type => ({ id: type.id, label: type.label, - dataSet: this.searchSet.filter(entity => entity.type === type), + dataSet: this.searchSet.filter(entity => entity.type === type.id), })) }, }, methods: { onCancel() { + /** + * Emitted when the user closed or cancelled + */ this.$emit('close') }, onSubmit() { - this.$emit('submit', this.selection) + /** + * Emitted when user submit the form + * @type {Array} the selected entities + */ + this.$emit('submit', Object.values(this.selection)) }, + + onSearch(event) { + /** + * Emitted when search change + * @type {string} the search query + */ + this.$emit('search', this.searchQuery) + }, + /** * Remove entity from selection * @param {Object} entity the entity to remove */ onDelete(entity) { - const index = this.selection.findIndex(search => search === entity) - this.selection.splice(index, 1) + this.$delete(this.selection, entity.id, entity) console.debug('Removing entity from selection', entity) }, + + /** + * Add entity from selection + * @param {Object} entity the entity to add + */ + onPick(entity) { + this.$set(this.selection, entity.id, entity) + console.debug('Added entity to selection', entity) + }, + + /** + * Toggle entity from selection + * @param {Object} entity the entity to add/remove + */ + onToggle(entity) { + if (entity.id in this.selection) { + this.onDelete(entity) + } else { + this.onPick(entity) + } + }, }, } @@ -205,6 +272,7 @@ export default { $dialog-margin: 20px; $dialog-width: 300px; $dialog-height: 480px; +$entity-spacing: 4px; // https://uxplanet.org/7-rules-for-mobile-ui-button-design-e9cf2ea54556 // recommended is 48px @@ -231,6 +299,7 @@ $icon-margin: ($clickable-area - $icon-size) / 2; width: $dialog-width - $dialog-margin * 2; height: $dialog-height - $dialog-margin * 2; margin: $dialog-margin; + max-height: calc(100vh - $dialog-margin * 2 - 10px); &__search { position: relative; @@ -238,10 +307,11 @@ $icon-margin: ($clickable-area - $icon-size) / 2; align-items: center; &-input { width: 100%; - height: $clickable-area !important; + height: $clickable-area - $entity-spacing !important; padding-left: $clickable-area; font-size: 16px; - line-height: $clickable-area; + line-height: $clickable-area - $entity-spacing; + margin: $entity-spacing 0; } &-icon { position: absolute; @@ -250,10 +320,6 @@ $icon-margin: ($clickable-area - $icon-size) / 2; } } - &__content { - height: 100%; - } - &__selection { display: flex; overflow-y: auto; @@ -262,8 +328,31 @@ $icon-margin: ($clickable-area - $icon-size) / 2; flex-wrap: wrap; // half a line height to know there is more lines max-height: 6.5em; - padding: 4px 0; + padding: $entity-spacing 0; border-bottom: 1px solid var(--color-background-darker); + background: var(--color-main-background); + } + + &__options { + margin: $entity-spacing 0; + overflow-y: auto; + } + &__option { + &-caption { + padding-left: 10px; + list-style-type: none; + user-select: none; + white-space: nowrap; + text-overflow: ellipsis; + pointer-events: none; + color: var(--color-primary); + box-shadow: none !important; + line-height: $clickable-area; + + &:not(:first-child) { + margin-top: $clickable-area / 2; + } + } } &__navigation { @@ -281,28 +370,6 @@ $icon-margin: ($clickable-area - $icon-size) / 2; } } -.entity-picker__options { - margin: 4px 0; - display: flex; - flex-direction: column; - justify-content: center; - &-caption { - color: var(--color-primary); - line-height: $clickable-area; - list-style-type: none; - white-space: nowrap; - text-overflow: ellipsis; - box-shadow: none !important; - user-select: none; - pointer-events: none; - padding-left: 10px; - - &:not(:first-child) { - margin-top: $clickable-area / 2; - } - } -} - /** Size full in the modal component doesn't have border radius, this adds it back */ ::v-deep .modal-container { diff --git a/src/components/EntityPicker/EntitySearchResult.vue b/src/components/EntityPicker/EntitySearchResult.vue index e0a25109..9962541a 100644 --- a/src/components/EntityPicker/EntitySearchResult.vue +++ b/src/components/EntityPicker/EntitySearchResult.vue @@ -22,12 +22,12 @@ <template> <UserBubble class="entity-picker__bubble" - :class="{'entity-picker__bubble--selected': selected}" + :class="{'entity-picker__bubble--selected': isSelected}" :display-name="label" :margin="6" :size="44" url="#" - @click="onClick"> + @click.stop.prevent="onClick"> <template #title> <span class="entity-picker__bubble-checkmark icon-checkmark" /> </template> @@ -64,15 +64,24 @@ export default { /** * Label of the entity */ - selected: { - type: Boolean, - default: false, + selection: { + type: Object, + required: true, + }, + }, + + computed: { + isSelected() { + return this.id in this.selection }, }, methods: { + /** + * Forward click to parent + * @param {Event} event the click event + */ onClick(event) { - console.info(event) this.$emit('click', event) }, }, diff --git a/src/components/ProcessingScreen.vue b/src/components/ProcessingScreen.vue new file mode 100644 index 00000000..e362187d --- /dev/null +++ b/src/components/ProcessingScreen.vue @@ -0,0 +1,57 @@ +<template> + <EmptyContent class="processing-screen__wrapper" icon="icon-contacts-dark"> + <slot /> + <template #desc> + <div class="processing-screen__progress"> + <progress :max="total" :value="progress" /> + </div> + </template> + </EmptyContent> +</template> + +<script> +import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent' + +export default { + name: 'ProcessingScreen', + + components: { + EmptyContent, + }, + + props: { + total: { + type: Number, + required: true, + }, + progress: { + type: Number, + required: true, + }, + }, +} +</script> + +<style lang="scss" scoped> +.processing-screen { + &__wrapper { + display: flex; + flex-direction: column; + width: auto; + min-width: 30vw; + margin: 50px; + + // Progress wrapper + &::v-deep > p { + display: block; + width: 80%; + margin: auto; + } + } + + &__progress { + width: 100%; + display: flex; + } +} +</style> diff --git a/src/services/appendContactToGroup.js b/src/services/appendContactToGroup.js new file mode 100644 index 00000000..b00cecc5 --- /dev/null +++ b/src/services/appendContactToGroup.js @@ -0,0 +1,41 @@ +/** + * @copyright Copyright (c) 2020 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 axios from '@nextcloud/axios' + +/** + * Append a group to a contact + * @param {Contact} contact the contact model + * @param {string} groupName the group name + */ +const appendContactToGroup = async function(contact, groupName) { + const groups = contact.groups + groups.push(groupName) + + return axios.patch(contact.url, {}, { + headers: { + 'X-PROPERTY': 'CATEGORIES', + 'X-PROPERTY-REPLACE': groups.join(','), + }, + }) +} + +export default appendContactToGroup diff --git a/src/store/contacts.js b/src/store/contacts.js index fed65587..2b9bc6af 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -314,7 +314,7 @@ const actions = { }, /** - * Replac a contact by this new object + * Replace a contact by this new object * * @param {Object} context the store mutations * @param {Contact} contact the contact to update diff --git a/src/views/Contacts.vue b/src/views/Contacts.vue index 0f4364a8..ded863e3 100644 --- a/src/views/Contacts.vue +++ b/src/views/Contacts.vue @@ -34,7 +34,7 @@ @click="newContact" /> <!-- groups list --> - <ul v-if="!loading" id="groups-list"> + <template v-if="!loading" #list> <!-- All contacts group --> <AppNavigationItem id="everyone" :title="GROUP_ALL_CONTACTS" @@ -102,12 +102,14 @@ @submit.prevent.stop="createNewGroup" /> </template> </AppNavigationItem> - </ul> + </template> <!-- settings --> - <AppNavigationSettings v-if="!loading"> - <SettingsSection /> - </AppNavigationSettings> + <template #footer> + <AppNavigationSettings v-if="!loading"> + <SettingsSection /> + </AppNavigationSettings> + </template> </AppNavigation> <AppContent> @@ -146,37 +148,55 @@ :data-set="pickerData" @close="onContactPickerClose" @submit="onContactPickerPick" /> + + <!-- Bulk contacts edit modal --> + <Modal v-if="isProcessing || isProcessDone" + :clear-view-delay="-1" + :can-close="isProcessDone" + @close="closeProcess"> + <ProcessingScreen v-bind="processStatus"> + {{ n('contacts', + 'Adding {total} contact to {name}', + 'Adding {total} contacts to {name}', + total, + processStatus + ) }} + </ProcessingScreen> + </Modal> </Content> </template> <script> +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import ActionInput from '@nextcloud/vue/dist/Components/ActionInput' import AppContent from '@nextcloud/vue/dist/Components/AppContent' import AppNavigation from '@nextcloud/vue/dist/Components/AppNavigation' -import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem' -import AppNavigationSpacer from '@nextcloud/vue/dist/Components/AppNavigationSpacer' import AppNavigationCounter from '@nextcloud/vue/dist/Components/AppNavigationCounter' +import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem' import AppNavigationNew from '@nextcloud/vue/dist/Components/AppNavigationNew' import AppNavigationSettings from '@nextcloud/vue/dist/Components/AppNavigationSettings' -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' -import ActionInput from '@nextcloud/vue/dist/Components/ActionInput' +import AppNavigationSpacer from '@nextcloud/vue/dist/Components/AppNavigationSpacer' import Content from '@nextcloud/vue/dist/Components/Content' -import Modal from '@nextcloud/vue/dist/Components/Modal' import isMobile from '@nextcloud/vue/dist/Mixins/isMobile' +import Modal from '@nextcloud/vue/dist/Components/Modal' -import moment from 'moment' -import download from 'downloadjs' import { VCardTime } from 'ical.js' +import download from 'downloadjs' +import moment from 'moment' +import pLimit from 'p-limit' -import SettingsSection from '../components/SettingsSection' -import ContactsList from '../components/ContactsList' import ContactDetails from '../components/ContactDetails' -import ImportScreen from '../components/ImportScreen' +import ContactsList from '../components/ContactsList' import EntityPicker from '../components/EntityPicker/EntityPicker' +import ImportScreen from '../components/ImportScreen' +import ProcessingScreen from '../components/ProcessingScreen' +import SettingsSection from '../components/SettingsSection' import Contact from '../models/contact' import rfcProps from '../models/rfcProps' import client from '../services/cdav' +import appendContactToGroup from '../services/appendContactToGroup' const GROUP_ALL_CONTACTS = t('contacts', 'All contacts') const GROUP_NO_GROUP_CONTACTS = t('contacts', 'Not grouped') @@ -185,21 +205,22 @@ export default { name: 'Contacts', components: { + ActionButton, + ActionInput, AppContent, AppNavigation, - AppNavigationItem, AppNavigationCounter, + AppNavigationItem, AppNavigationNew, AppNavigationSettings, AppNavigationSpacer, - ActionButton, - ActionInput, ContactDetails, ContactsList, Content, - ImportScreen, EntityPicker, + ImportScreen, Modal, + ProcessingScreen, SettingsSection, }, @@ -227,13 +248,24 @@ export default { isCreatingGroup: false, isNewGroupMenuOpen: false, loading: true, + + // Add to group picker searchQuery: '', - showContactPicker: true, + showContactPicker: false, contactPickerforGroup: null, pickerTypes: [{ - id: 'contacts', + id: 'contact', label: t('contacts', 'contacts'), }], + + // Bulk processing + isProcessing: false, + isProcessDone: false, + processStatus: { + total: 0, + progress: 0, + name: '', + }, } }, @@ -580,9 +612,50 @@ export default { }, onContactPickerPick(selection) { - const group = this.contactPickerforGroup - console.info('Adding', selection, 'to group', group) + console.debug('Adding', selection, 'to group', this.contactPickerforGroup) + const groupName = this.contactPickerforGroup.name + + this.isProcessing = true + + this.processStatus.total = selection.length + this.processStatus.name = this.contactPickerforGroup.name + + // max simultaneous requests + const limit = pLimit(3) + const requests = [] + + // create the array of requests to send + selection.map(async entity => { + try { + // Get contact + const contact = this.contacts[entity.id] + + // push contact to server and use limit + requests.push(limit(() => appendContactToGroup(contact, groupName) + .then((response) => { + this.$store.dispatch('addContactToGroup', { contact, groupName }) + this.processStatus.progress++ + }) + .catch((error) => { + this.processStatus.progress++ + console.error(error) + }) + )) + } catch (e) { + console.error(e) + } + }) + + Promise.all(requests).then(() => { + this.isProcessDone = true + this.showContactPicker = false + }) + }, + + closeProcess() { this.contactPickerforGroup = null + this.isProcessing = false + this.isProcessDone = false }, }, |