summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorJohn Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>2020-07-02 17:49:42 +0200
committerJohn Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>2020-08-21 09:56:08 +0200
commitf44028131344636e45c5158cc13ccbe4edd19097 (patch)
tree12023f8c4d62c6ba173e572f89e15c1565799446 /src
parent63b2aff43903d51fc382a7c6cb0019845363c183 (diff)
Add PatchPlugin
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/components/EntityPicker/EntityBubble.vue25
-rw-r--r--src/components/EntityPicker/EntityPicker.vue215
-rw-r--r--src/components/EntityPicker/EntitySearchResult.vue21
-rw-r--r--src/components/ProcessingScreen.vue57
-rw-r--r--src/services/appendContactToGroup.js41
-rw-r--r--src/store/contacts.js2
-rw-r--r--src/views/Contacts.vue119
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
},
},