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 | |
parent | 63b2aff43903d51fc382a7c6cb0019845363c183 (diff) |
Add PatchPlugin
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
-rw-r--r-- | appinfo/info.xml | 4 | ||||
-rw-r--r-- | img/recent-actors.svg | 1 | ||||
-rw-r--r-- | lib/AppInfo/Application.php | 28 | ||||
-rw-r--r-- | lib/Dav/PatchPlugin.php | 186 | ||||
-rw-r--r-- | package-lock.json | 34 | ||||
-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 |
12 files changed, 614 insertions, 119 deletions
diff --git a/appinfo/info.xml b/appinfo/info.xml index 1a943d67..931b4b8a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -33,6 +33,10 @@ <bugs>https://github.com/nextcloud/contacts/issues</bugs> <repository type="git">https://github.com/nextcloud/contacts.git</repository> + <!-- required for dav plugins registration --> + <types> + <filesystem/> + </types> <dependencies> <nextcloud min-version="17" max-version="20" /> diff --git a/img/recent-actors.svg b/img/recent-actors.svg new file mode 100644 index 00000000..c47e2a6d --- /dev/null +++ b/img/recent-actors.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M14 3v10h1V3zM12 13h1V3h-1zM1.7 3a.7.7 0 00-.7.7v8.6c0 .4.3.7.7.7h8.6c.4 0 .7-.3.7-.7V3.7a.7.7 0 00-.7-.7zM6 5a1.6 1.6 0 010 3.2A1.6 1.6 0 016 5zm0 4.4c1 0 3.2.6 3.2 1.6v.6H2.8V11c0-1 2.1-1.6 3.2-1.6z"/></svg>
\ No newline at end of file diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 565fe5b7..a2f3a396 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -22,16 +22,36 @@ */ namespace OCA\Contacts\AppInfo; +use OCA\Contacts\Dav\PatchPlugin; use OCP\AppFramework\App; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\SabrePluginEvent; class Application extends App { public const APP_ID = 'contacts'; - - public function __construct() { - parent::__construct(self::APP_ID); - } public const AVAIL_SETTINGS = [ 'allowSocialSync' => 'yes', ]; + + public function __construct() { + parent::__construct(self::APP_ID); + } + + public function register() { + $server = $this->getContainer()->getServer(); + + /** @var IEventDispatcher $eventDispatcher */ + $eventDispatcher = $server->query(IEventDispatcher::class); + $eventDispatcher->addListener('OCA\DAV\Connector\Sabre::addPlugin', function (SabrePluginEvent $event) { + $server = $event->getServer(); + + if ($server !== null) { + // We have to register the LockPlugin here and not info.xml, + // because info.xml plugins are loaded, after the + // beforeMethod:* hook has already been emitted. + $server->addPlugin($this->getContainer()->query(PatchPlugin::class)); + } + }); + } } diff --git a/lib/Dav/PatchPlugin.php b/lib/Dav/PatchPlugin.php new file mode 100644 index 00000000..61f36b7a --- /dev/null +++ b/lib/Dav/PatchPlugin.php @@ -0,0 +1,186 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ (skjnldsv) <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/>. + * + */ + +namespace OCA\Contacts\Dav; + +use Sabre\CardDAV\Card; +use Sabre\DAV; +use Sabre\DAV\INode; +use Sabre\DAV\PropPatch; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Sabre\VObject\Component\VCard; +use Sabre\VObject\Reader; + +class PatchPlugin extends ServerPlugin { + public const METHOD_REPLACE = 0; + public const METHOD_APPEND = 1; + + /** @var Server */ + protected $server; + + /** + * Initializes the plugin and registers event handlers + * + * @param Server $server + * @return void + */ + public function initialize(Server $server) { + $this->server = $server; + $server->on('method:PATCH', [$this, 'httpPatch']); + } + + /** + * Use this method to tell the server this plugin defines additional + * HTTP methods. + * + * This method is passed a uri. It should only return HTTP methods that are + * available for the specified uri. + * + * We claim to support PATCH method (partirl update) if and only if + * - the node exist + * - the node implements our partial update interface + * + * @param string $uri + * + * @return array + */ + public function getHTTPMethods($uri) { + $tree = $this->server->tree; + + if ($tree->nodeExists($uri)) { + $node = $tree->getNodeForPath($uri); + if ($node instanceof Card) { + return ['PATCH']; + } + } + + return []; + } + + /** + * Adds all CardDAV-specific properties + * + * @param PropPatch $propPatch + * @param INode $node + * @return void + */ + public function httpPatch(RequestInterface $request, ResponseInterface $response) { + $path = $request->getPath(); + $node = $this->server->tree->getNodeForPath($path); + + if (!($node instanceof Card)) { + return true; + } + + // Checking ACL, if available. + if ($aclPlugin = $this->server->getPlugin('acl')) { + /** @var \Sabre\DAVACL\Plugin $aclPlugin */ + $aclPlugin->checkPrivileges($path, '{DAV:}write'); + } + + // Init property name & value + $propertyName = $request->getHeader('X-Property'); + if (is_null($propertyName)) { + throw new DAV\Exception\BadRequest('No valid "X-Property" found in the headers'); + } + + $propertyData = $request->getHeader('X-Property-Replace'); + $method = self::METHOD_REPLACE; + if (is_null($propertyData)) { + $propertyData = $request->getHeader('X-Property-Append'); + $method = self::METHOD_APPEND; + if (is_null($propertyData)) { + throw new DAV\Exception\BadRequest('No valid "X-Property-Append" or "X-Property-Replace" found in the headers'); + } + } + + // Init contact + $vCard = Reader::read($node->get()); + $properties = $vCard->select($propertyName); + + // We cannot know which one to update in that case + if (count($properties) > 1) { + throw new DAV\Exception\BadRequest('The specified property appear more than once'); + } + + // Init if not in the vcard + if (count($properties) === 0) { + $vCard->add($propertyName, $propertyData); + $properties = $vCard->select($propertyName); + } + + // Replace existing value + if ($method === self::METHOD_REPLACE) { + $properties[0]->setRawMimeDirValue($propertyData); + } + + // Append to existing value + if ($method === self::METHOD_APPEND) { + $oldData = $properties[0]->getValue(); + $properties[0]->setRawMimeDirValue($oldData.$propertyData); + } + + // Validate & write + $vCard->validate(); + $node->put($vCard->serialize()); + $response->setStatus(200); + + return false; + } + + /** + * Returns a plugin name. + * + * Using this name other plugins will be able to access other plugins + * using \Sabre\DAV\Server::getPlugin + * + * @return string + */ + public function getPluginName() { + return 'vcard-patch'; + } + + /** + * Returns a bunch of meta-data about the plugin. + * + * Providing this information is optional, and is mainly displayed by the + * Browser plugin. + * + * The description key in the returned array may contain html and will not + * be sanitized. + * + * @return array + */ + public function getPluginInfo() { + return [ + 'name' => $this->getPluginName(), + 'description' => 'Allow to patch unique properties.' + ]; + } +} diff --git a/package-lock.json b/package-lock.json index 74baae90..90627ef4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2673,9 +2673,15 @@ } }, "@nextcloud/vue": { +<<<<<<< HEAD "version": "2.3.0", "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-2.3.0.tgz", "integrity": "sha512-6uf7Hu4Obaet7BOs9H/Ng63xAYqks9CL7hsOOHGUzWFYrPPBxgt79iD9OOPpPfJuLQ3Nnuibh942X1QreCBRkw==", +======= + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-2.2.0.tgz", + "integrity": "sha512-F7KA39DrBQT/IFY42rqfcA0NvOqQ06PUtI6Htph5quXXgXdvqIqRSb+w2/aWkmprKwHRaBMtCX3Dxrd+uGdqpw==", +>>>>>>> 90efae4b... Add PatchPlugin "requires": { "@nextcloud/auth": "^1.2.3", "@nextcloud/axios": "^1.3.2", @@ -4349,9 +4355,15 @@ } }, "date-fns": { +<<<<<<< HEAD "version": "2.15.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.15.0.tgz", "integrity": "sha512-ZCPzAMJZn3rNUvvQIMlXhDr4A+Ar07eLeGsGREoWU19a3Pqf5oYa+ccd+B3F6XVtQY6HANMFdOQ8A+ipFnvJdQ==" +======= + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.14.0.tgz", + "integrity": "sha512-1zD+68jhFgDIM0rF05rcwYO8cExdNqxjq4xP1QKM60Q45mnO6zaMWB4tOzrIr4M4GSLntsKeE4c9Bdl2jhL/yw==" +>>>>>>> 90efae4b... Add PatchPlugin }, "date-format-parse": { "version": "0.2.5", @@ -6016,6 +6028,11 @@ "brace-expansion": "^1.1.7" } }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + }, "minipass": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", @@ -6042,6 +6059,14 @@ "optional": true, "requires": { "minimist": "^1.2.5" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "optional": true + } } }, "ms": { @@ -7958,7 +7983,8 @@ "minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true }, "minimist-options": { "version": "3.0.2", @@ -11945,9 +11971,15 @@ } }, "webpack-node-externals": { +<<<<<<< HEAD "version": "2.5.1", "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-2.5.1.tgz", "integrity": "sha512-RWxKGibUU5kuJT6JDYmXGa3QsZskqIaiBvZ2wBxHlJzWVJPOyBMnroXf23uxEHnj1rYS8jNdyUfrNAXJ2bANNw==", +======= + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-2.5.0.tgz", + "integrity": "sha512-g7/Z7Q/gsP8GkJkKZuJggn6RSb5PvxW1YD5vvmRZIxaSxAzkqjfL5n9CslVmNYlSqBVCyiqFgOqVS2IOObCSRg==", +>>>>>>> 90efae4b... Add PatchPlugin "dev": true }, "webpack-sources": { 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" |