summaryrefslogtreecommitdiffstats
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
parent63b2aff43903d51fc382a7c6cb0019845363c183 (diff)
Add PatchPlugin
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
-rw-r--r--appinfo/info.xml4
-rw-r--r--img/recent-actors.svg1
-rw-r--r--lib/AppInfo/Application.php28
-rw-r--r--lib/Dav/PatchPlugin.php186
-rw-r--r--package-lock.json34
-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
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"