Merge pull request #1687 from nextcloud/enh/add-virtual-group

This commit is contained in:
John Molakvoæ 2020-08-21 10:08:00 +02:00 committed by GitHub
commit 87615fb654
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1592 additions and 193 deletions

View File

@ -24,3 +24,4 @@
use OCA\Contacts\AppInfo\Application;
$app = \OC::$server->query(Application::class);
$app->register();

View File

@ -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" />

View File

@ -33,17 +33,3 @@
border-radius: 50%;
opacity: 1;
}
// Virtual scroller overrides
.vue-recycle-scroller {
position: sticky !important;
}
.vue-recycle-scroller__item-view {
// TODO: find better solution?
// https://github.com/Akryum/vue-virtual-scroller/issues/70
// hack to not show the transition
overflow: hidden;
// same as app-content-list-item
height: 68px;
}

View File

@ -31,6 +31,7 @@
@include icon-black-white('language', 'contacts', 2);
@include icon-black-white('clone', 'contacts', 2);
@include icon-black-white('sync', 'contacts', 2);
@include icon-black-white('recent-actors', 'contacts', 1);
// social network icons:
@include icon-black-white('facebook', 'contacts', 2); // facebook (fab) by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/facebook?style=brands)

1
img/recent-actors.svg Normal file
View File

@ -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>

After

Width:  |  Height:  |  Size: 281 B

View File

@ -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));
}
});
}
}

View File

@ -24,15 +24,16 @@
namespace OCA\Contacts\Controller;
use OCA\Contacts\Service\SocialApiService;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\TemplateResponse;
use OCA\Contacts\AppInfo\Application;
use OCA\Contacts\Service\SocialApiService;
use OCP\IConfig;
use OCP\IInitialStateService;
use OCP\IUserSession;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Util;
@ -52,20 +53,24 @@ class PageController extends Controller {
/** @var SocialApiService */
private $socialApiService;
/** @var IAppManager */
private $appManager;
public function __construct(IRequest $request,
IConfig $config,
IInitialStateService $initialStateService,
IFactory $languageFactory,
IUserSession $userSession,
SocialApiService $socialApiService) {
SocialApiService $socialApiService,
IAppManager $appManager) {
parent::__construct(Application::APP_ID, $request);
$this->appName = Application::APP_ID;
$this->config = $config;
$this->initialStateService = $initialStateService;
$this->languageFactory = $languageFactory;
$this->userSession = $userSession;
$this->socialApiService = $socialApiService;
$this->appManager = $appManager;
}
/**
@ -82,23 +87,24 @@ class PageController extends Controller {
}
$locales = $this->languageFactory->findAvailableLocales();
$defaultProfile = $this->config->getAppValue($this->appName, 'defaultProfile', 'HOME');
$defaultProfile = $this->config->getAppValue(Application::APP_ID, 'defaultProfile', 'HOME');
$supportedNetworks = $this->socialApiService->getSupportedNetworks();
$syncAllowedByAdmin = $this->config->getAppValue($this->appName, 'allowSocialSync', 'yes'); // allow users to retrieve avatars from social networks (default: yes)
$bgSyncEnabledByUser = $this->config->getUserValue($userId, $this->appName, 'enableSocialSync', 'no'); // automated background syncs for social avatars (default: no)
$syncAllowedByAdmin = $this->config->getAppValue(Application::APP_ID, 'allowSocialSync', 'yes'); // allow users to retrieve avatars from social networks (default: yes)
$bgSyncEnabledByUser = $this->config->getUserValue($userId, Application::APP_ID, 'enableSocialSync', 'no'); // automated background syncs for social avatars (default: no)
$this->initialStateService->provideInitialState($this->appName, 'locales', $locales);
$this->initialStateService->provideInitialState($this->appName, 'defaultProfile', $defaultProfile);
$this->initialStateService->provideInitialState($this->appName, 'supportedNetworks', $supportedNetworks);
$this->initialStateService->provideInitialState($this->appName, 'locales', $locales);
$this->initialStateService->provideInitialState($this->appName, 'defaultProfile', $defaultProfile);
$this->initialStateService->provideInitialState($this->appName, 'supportedNetworks', $supportedNetworks);
$this->initialStateService->provideInitialState($this->appName, 'allowSocialSync', $syncAllowedByAdmin);
$this->initialStateService->provideInitialState($this->appName, 'enableSocialSync', $bgSyncEnabledByUser);
$this->initialStateService->provideInitialState(Application::APP_ID, 'locales', $locales);
$this->initialStateService->provideInitialState(Application::APP_ID, 'defaultProfile', $defaultProfile);
$this->initialStateService->provideInitialState(Application::APP_ID, 'supportedNetworks', $supportedNetworks);
$this->initialStateService->provideInitialState(Application::APP_ID, 'locales', $locales);
$this->initialStateService->provideInitialState(Application::APP_ID, 'defaultProfile', $defaultProfile);
$this->initialStateService->provideInitialState(Application::APP_ID, 'supportedNetworks', $supportedNetworks);
$this->initialStateService->provideInitialState(Application::APP_ID, 'allowSocialSync', $syncAllowedByAdmin);
$this->initialStateService->provideInitialState(Application::APP_ID, 'enableSocialSync', $bgSyncEnabledByUser);
$this->initialStateService->provideInitialState(Application::APP_ID, 'contactsinteraction', $this->appManager->isEnabledForUser('contactsinteraction') === true);
Util::addScript($this->appName, 'contacts');
Util::addStyle($this->appName, 'contacts');
Util::addScript(Application::APP_ID, 'contacts');
Util::addStyle(Application::APP_ID, 'contacts');
return new TemplateResponse($this->appName, 'main');
return new TemplateResponse(Application::APP_ID, 'main');
}
}

186
lib/Dav/PatchPlugin.php Normal file
View File

@ -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.'
];
}
}

93
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "contacts",
"version": "3.3.0",
"version": "3.4.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -3768,8 +3768,8 @@
"dev": true
},
"cdav-library": {
"version": "git+https://github.com/nextcloud/cdav-library.git#c2002a9f4e327ff8ae188ba1d5fc977f60b486a0",
"from": "git+https://github.com/nextcloud/cdav-library.git#c2002a9f4e327ff8ae188ba1d5fc977f60b486a0",
"version": "git+https://github.com/nextcloud/cdav-library.git#a41be4f7c4793ce9b681151ad33dc872989e701d",
"from": "git+https://github.com/nextcloud/cdav-library.git",
"requires": {
"core-js": "^3.6.5",
"regenerator-runtime": "^0.13.7"
@ -4247,43 +4247,26 @@
}
},
"css-loader": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-4.2.1.tgz",
"integrity": "sha512-MoqmF1if7Z0pZIEXA4ZF9PgtCXxWbfzfJM+3p+OYfhcrwcqhaCRb74DSnfzRl7e024xEiCRn5hCvfUbTf2sgFA==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
"integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
"dev": true,
"requires": {
"camelcase": "^6.0.0",
"camelcase": "^5.3.1",
"cssesc": "^3.0.0",
"icss-utils": "^4.1.1",
"loader-utils": "^2.0.0",
"loader-utils": "^1.2.3",
"normalize-path": "^3.0.0",
"postcss": "^7.0.32",
"postcss-modules-extract-imports": "^2.0.0",
"postcss-modules-local-by-default": "^3.0.3",
"postcss-modules-local-by-default": "^3.0.2",
"postcss-modules-scope": "^2.2.0",
"postcss-modules-values": "^3.0.0",
"postcss-value-parser": "^4.1.0",
"schema-utils": "^2.7.0",
"semver": "^7.3.2"
"semver": "^6.3.0"
},
"dependencies": {
"camelcase": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.0.0.tgz",
"integrity": "sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==",
"dev": true
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"postcss": {
"version": "7.0.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz",
@ -4295,12 +4278,6 @@
"supports-color": "^6.1.0"
}
},
"semver": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
"integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
"dev": true
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -4657,9 +4634,9 @@
}
},
"emoji-mart-vue-fast": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/emoji-mart-vue-fast/-/emoji-mart-vue-fast-7.0.2.tgz",
"integrity": "sha512-7OftneG98Jb9wkJgPBeUdEWrMMdGvd08erHMjNviKSuQSBsBAIFnQyW7lroBZ+dLT7uTZKuZfWdWwUfWpk965w==",
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/emoji-mart-vue-fast/-/emoji-mart-vue-fast-7.0.4.tgz",
"integrity": "sha512-VZuyclCe7ZNPhSvt7WT258MscqRBZTB2Is/7vBilCXgpiZqByaA4AhM1xdIIZZik/aA+5BQiZVmbsDK0jk78Eg==",
"requires": {
"@babel/polyfill": "7.2.5",
"@babel/runtime": "7.3.4",
@ -6016,6 +5993,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 +6024,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": {
@ -6191,6 +6181,14 @@
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"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
}
}
},
"readable-stream": {
@ -7721,13 +7719,13 @@
"dev": true
},
"md5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz",
"integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"requires": {
"charenc": "~0.0.1",
"crypt": "~0.0.1",
"is-buffer": "~1.1.1"
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"md5.js": {
@ -11665,6 +11663,11 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"vue-virtual-scroll-list": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vue-virtual-scroll-list/-/vue-virtual-scroll-list-2.3.0.tgz",
"integrity": "sha512-A0Cyvj9+oqsHoA95jH77kP8QaYzOm71aq4LiOvPXLQ7AVK73raDEy09Kbh+RQbWz1iw5I3++UU2WOmHYIHwN8A=="
},
"vue-virtual-scroller": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-1.0.10.tgz",
@ -11681,9 +11684,9 @@
"integrity": "sha512-yaX2its9XAJKGuQqf7LsiZHHSkxsIK8rmCOQOvEGEoF41blKRK8qr9my4qYoD6ikdLss4n8tKqYBecmaY0+WJg=="
},
"vue2-datepicker": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vue2-datepicker/-/vue2-datepicker-3.6.1.tgz",
"integrity": "sha512-U6iQWSDsNoq/u6QJCtAMcyWlcZSx0rmPmqaJ8LQtGvwu9x12jXDoe3YNeG4y7E45OYAMLXs9WzGkDqDmNj3jkw==",
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/vue2-datepicker/-/vue2-datepicker-3.6.2.tgz",
"integrity": "sha512-J2fCwUmCxIOPUvwQ12e8evFY9cCv6vJmgxRD9fGeUv6JeMMeLwkdpeQZOcqbMf/4mk1cSrY2/9Fr8DaB30LBpA==",
"requires": {
"date-fns": "^2.0.1",
"date-format-parse": "^0.2.5"

View File

@ -1,7 +1,7 @@
{
"name": "contacts",
"description": "A contacts app for Nextcloud. Easily sync contacts from various devices, share and edit them online.",
"version": "3.3.0",
"version": "3.4.0",
"author": "John Molakvoæ <skjnldsv@protonmail.com>",
"contributors": [
"John Molakvoæ <skjnldsv@protonmail.com>",
@ -58,6 +58,7 @@
"vue-click-outside": "^1.1.0",
"vue-clipboard2": "^0.3.1",
"vue-router": "^3.4.3",
"vue-virtual-scroll-list": "^2.3.0",
"vue-virtual-scroller": "^1.0.10",
"vuex": "^3.4.0",
"vuex-router-sync": "^5.0.0"
@ -75,7 +76,7 @@
"@nextcloud/eslint-plugin": "^1.4.0",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.1.0",
"css-loader": "^4.2.1",
"css-loader": "^3.6.0",
"eslint": "^6.8.0",
"eslint-config-standard": "^14.1.1",
"eslint-loader": "^4.0.2",

View File

@ -23,17 +23,17 @@
<template>
<div id="contact-details" class="app-content-details">
<!-- nothing selected or contact not found -->
<div v-if="!contact && !loading" id="emptycontent">
<div class="icon-contacts" />
<h2>{{ t('contacts', 'No contact selected') }}</h2>
<p>{{ t('contacts', 'Select a contact on the list to begin') }}</p>
</div>
<EmptyContent v-if="!contact && !loading" icon="icon-contacts-dark">
{{ t('contacts', 'No contact selected') }}
<template #desc>
{{ t('contacts', 'Select a contact on the list to begin') }}
</template>
</EmptyContent>
<!-- loading -->
<div v-else-if="loading" id="emptycontent">
<div class="icon-contacts" />
<h2>{{ t('contacts', 'Loading') }}</h2>
</div>
<EmptyContent v-else-if="loading" icon="icon-contacts-dark">
{{ t('contacts', 'Loading contacts …') }}
</EmptyContent>
<template v-else>
<!-- contact header -->
@ -105,7 +105,7 @@
show: true,
trigger: 'manual',
}"
class="header-icon header-icon--pulse icon-history-force-white"
class="header-icon header-icon--pulse icon-history"
@click="refreshContact" />
<!-- repaired contact message -->
@ -115,7 +115,7 @@
show: true,
trigger: 'manual',
}"
class="header-icon header-icon--pulse icon-up-force-white"
class="header-icon header-icon--pulse icon-up"
@click="updateContact" />
<!-- menu actions -->
@ -244,6 +244,7 @@ import validate from '../services/validate'
import AddNewProp from './ContactDetails/ContactDetailsAddNewProp'
import ContactAvatar from './ContactDetails/ContactDetailsAvatar'
import ContactProperty from './ContactDetails/ContactDetailsProperty'
import EmptyContent from './EmptyContent'
import PropertyGroups from './Properties/PropertyGroups'
import PropertyRev from './Properties/PropertyRev'
import PropertySelect from './Properties/PropertySelect'
@ -254,17 +255,18 @@ export default {
name: 'ContactDetails',
components: {
Actions,
ActionButton,
ActionLink,
Actions,
AddNewProp,
ContactAvatar,
ContactProperty,
EmptyContent,
Modal,
Multiselect,
PropertyGroups,
PropertyRev,
PropertySelect,
Modal,
Multiselect,
},
props: {
@ -314,12 +316,12 @@ export default {
warning() {
if (!this.contact.dav) {
return {
icon: 'icon-error-white header-icon--pulse',
icon: 'icon-error header-icon--pulse',
msg: t('contacts', 'This contact is not yet synced. Edit it to save it to the server.'),
}
} else if (this.isReadOnly) {
return {
icon: 'icon-eye-white',
icon: 'icon-eye',
msg: t('contacts', 'This contact is in read-only mode. You do not have permission to edit this contact.'),
}
}

View File

@ -24,6 +24,7 @@
<!-- same uid can coexists between different addressbooks
so we need to use the addressbook id as key as well -->
<RecycleScroller
v-if="haveContact"
id="contacts-list"
ref="scroller"
:class="{'icon-loading': loading, showdetails: selectedContact}"
@ -40,10 +41,22 @@
@deleted="selectContact" />
</template>
</RecycleScroller>
<div v-else class="app-content-list">
<EmptyContent>
{{ t('contacts', 'No contacts in this group') }}
<template #action>
<button class="primary" @click="onAddContactsToGroup">
{{ t('forms', 'Add some') }}
</button>
</template>
</EmptyContent>
</div>
</template>
<script>
import ContactsListItem from './ContactsList/ContactsListItem'
import EmptyContent from './EmptyContent'
import { RecycleScroller } from 'vue-virtual-scroller/dist/vue-virtual-scroller.umd.js'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
@ -52,6 +65,7 @@ export default {
components: {
ContactsListItem,
EmptyContent,
RecycleScroller,
},
@ -90,6 +104,9 @@ export default {
filteredList() {
return this.list.filter(contact => this.matchSearch(this.contacts[contact.key]))
},
haveContact() {
return this.selectedGroup && this.filteredList.length > 0
},
},
watch: {
@ -160,6 +177,26 @@ export default {
}
return true
},
onAddContactsToGroup() {
this.$emit('onAddContactsToGroup')
},
},
}
</script>
<style lang="scss" scoped>
// Virtual scroller overrides
.vue-recycle-scroller {
position: sticky !important;
}
.vue-recycle-scroller__item-view {
// TODO: find better solution?
// https://github.com/Akryum/vue-virtual-scroller/issues/70
// hack to not show the transition
overflow: hidden;
// same as app-content-list-item
height: 68px;
}
</style>

View File

@ -0,0 +1,77 @@
<!--
- @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/>.
-
-->
<template>
<div class="empty-content" role="note">
<div class="empty-content__icon" :class="icon" role="img" />
<h2 class="empty-content__title">
<slot />
</h2>
<p v-show="$slots.desc" class="empty-content__desc">
<slot name="desc" />
</p>
<div v-show="$slots.action" class="empty-content__action">
<slot name="action" />
</div>
</div>
</template>
<script>
export default {
name: 'EmptyContent',
props: {
icon: {
type: String,
default: 'icon-forms',
},
},
}
</script>
<style lang="scss">
.empty-content {
margin-top: 20vh;
display: flex;
flex-direction: column;
align-items: center;
&__icon {
width: 64px;
height: 64px;
margin: 0 auto 15px;
opacity: .4;
background-size: 64px;
background-repeat: no-repeat;
background-position: center;
}
&__title {
margin-bottom: 8px;
}
&__desc {
margin-bottom: 16px;
}
}
</style>

View File

@ -0,0 +1,114 @@
<!--
- @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
- @author Team Popcorn <teampopcornberlin@gmail.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/>.
-
-->
<template>
<UserBubble
class="entity-picker__bubble"
:margin="0"
:size="22"
:display-name="label">
<template #title>
<a href="#"
:title="t('contacts', 'Remove {type}', { type })"
class="entity-picker__bubble-delete icon-close"
@click="onDelete" />
</template>
</UserBubble>
</template>
<script>
import UserBubble from '@nextcloud/vue/dist/Components/UserBubble'
export default {
name: 'EntityBubble',
components: {
UserBubble,
},
props: {
/**
* Unique id of the entity
*/
id: {
type: String,
required: true,
},
/**
* Label of the entity
*/
label: {
type: String,
required: true,
},
/**
* Type of the entity. e.g user, circle, group...
*/
type: {
type: String,
required: true,
},
},
methods: {
onDelete() {
// Emit delete. Be aware it might be unique
// amongst their types, but not unique amongst
// the whole selection all. Make sure to
// properly compare all necessary data.
this.$emit('delete', {
id: this.id,
type: this.type,
})
},
},
}
</script>
<style lang="scss" scoped>
// better visual with light default tint
::v-deep .user-bubble__content {
background-color: var(--color-primary-light);
}
.entity-picker__bubble {
// Add space between bubbles
margin-right: 4px;
&-delete {
display: block;
height: 100%;
// squeeze in the border radius
margin-right: -4px;
opacity: .7;
&:hover,
&:active,
&:focus {
opacity: 1;
}
}
}
</style>

View File

@ -0,0 +1,397 @@
<!--
- @copyright Copyright (c) 2019 Marco Ambrosini <marcoambrosini@pm.me>
-
- @author Marco Ambrosini <marcoambrosini@pm.me>
-
- @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/>.
-->
<template>
<Modal
size="full"
@close="onCancel">
<!-- Wrapper for content & navigation -->
<div
class="entity-picker">
<!-- Search -->
<div class="entity-picker__search">
<div class="entity-picker__search-icon icon-search" />
<input
v-model="searchQuery"
class="entity-picker__search-input"
type="search"
:placeholder="t('contacts', 'Search {types}', {types: searchPlaceholderTypes})"
@change="onSearch">
</div>
<!-- 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 -->
<VirtualList v-else-if="searchSet.length > 0 && availableEntities.length > 0"
class="entity-picker__options"
data-key="id"
:data-sources="availableEntities"
:data-component="EntitySearchResult"
:estimate-size="44"
:extra-props="{selection, onClick: onPick}" />
<EmptyContent v-else-if="searchQuery" icon="icon-search">
{{ t('contacts', 'No results') }}
</EmptyContent>
<div class="entity-picker__navigation">
<button
class="navigation__button-left"
@click="onCancel">
{{ t('contacts', 'Cancel') }}
</button>
<button
:disabled="isEmptySelection"
class="navigation__button-right primary"
@click="onSubmit">
{{ t('contacts', 'Add to group') }}
</button>
</div>
</div>
</modal>
</template>
<script>
import Modal from '@nextcloud/vue/dist/Components/Modal'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import VirtualList from 'vue-virtual-scroll-list'
import EntityBubble from './EntityBubble'
import EntitySearchResult from './EntitySearchResult'
export default {
name: 'EntityPicker',
components: {
EmptyContent,
EntityBubble,
Modal,
VirtualList,
},
props: {
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
},
},
/**
* The data to be used
*/
dataSet: {
type: Array,
required: true,
},
/**
* The sorting key for the dataSet
*/
sort: {
type: String,
default: 'label',
},
},
data() {
return {
searchQuery: '',
selection: {},
EntitySearchResult,
}
},
computed: {
/**
* Are we handling a single entity type ?
* @returns {boolean}
*/
isSingleType() {
return !(this.dataTypes.length > 1)
},
/**
* Is the current selection empty
* @returns {boolean}
*/
isEmptySelection() {
return Object.keys(this.selection).length === 0
},
/**
* Formatted search input placeholder based on
* available types
* @returns {string}
*/
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
* @returns {Object[]}
*/
searchSet() {
if (this.searchQuery && this.searchQuery.trim !== '') {
return this.dataSet.filter(entity => {
return entity.label.indexOf(this.searchQuery) > -1
})
}
return this.dataSet
},
/**
* Returns available entities grouped by type(s) if any
* @returns {Object[]}
*/
availableEntities() {
// If only one type, return the full set directly
if (this.isSingleType) {
return this.searchSet
}
// 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()
},
},
methods: {
onCancel() {
/**
* Emitted when the user closed or cancelled
*/
this.$emit('close')
},
onSubmit() {
/**
* 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) {
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)
}
},
},
}
</script>
<style lang="scss" scoped>
// Dialog variables
$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
// 44px is what we choose and have very good visual-to-usability ratio
$clickable-area: 44px;
// background icon size
// also used for the scss icon font
$icon-size: 16px;
// icon padding for a $clickable-area width and a $icon-size icon
// ( 44px - 16px ) / 2
$icon-margin: ($clickable-area - $icon-size) / 2;
.entity-picker {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
/** This next 2 rules are pretty hacky, with the modal component somehow
the margin applied to the content is added to the total modal width,
so here we subtract it to the width and height of the content.
*/
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;
display: flex;
align-items: center;
&-input {
width: 100%;
height: $clickable-area - $entity-spacing !important;
padding-left: $clickable-area;
font-size: 16px;
line-height: $clickable-area - $entity-spacing;
margin: $entity-spacing 0;
}
&-icon {
position: absolute;
width: $clickable-area;
height: $clickable-area;
}
}
&__selection {
display: flex;
overflow-y: auto;
align-content: flex-start;
flex: 1 0 auto;
flex-wrap: wrap;
// half a line height to know there is more lines
max-height: 6.5em;
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;
}
&__navigation {
z-index: 1;
display: flex;
// define our base width, no shrinkage
flex: 0 0;
justify-content: space-between;
// Same as above
width: $dialog-width - $dialog-margin * 2;
box-shadow: 0 -10px 5px var(--color-main-background);
&__button-right {
margin-left: auto;
}
}
}
// Properly center Entity Picker empty content
.empty-content {
margin: 0;
}
/** Size full in the modal component doesn't have border radius, this adds
it back */
::v-deep .modal-container {
border-radius: var(--border-radius-large) !important;
}
</style>
<style lang="scss" scoped>
.zoom-enter-active {
animation: zoom-in var(--animation-quick);
}
.zoom-leave-active {
animation: zoom-in var(--animation-quick) reverse;
will-change: transform;
}
@keyframes zoom-in {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
</style>

View File

@ -0,0 +1,151 @@
<!--
- @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/>.
-->
<template>
<h4 v-if="source.heading" :key="source.id" class="entity-picker__option-caption">
{{ t('contacts', 'Add {type}', {type: source.label.toLowerCase()}) }}
</h4>
<UserBubble
v-else
class="entity-picker__bubble"
:class="{'entity-picker__bubble--selected': isSelected}"
:display-name="source.label"
:margin="6"
:size="44"
url="#"
@click.stop.prevent="onClick(source)">
<template #title>
<span class="entity-picker__bubble-checkmark icon-checkmark" />
</template>
</UserBubble>
</template>
<script>
import UserBubble from '@nextcloud/vue/dist/Components/UserBubble'
export default {
name: 'EntitySearchResult',
components: {
UserBubble,
},
props: {
source: {
type: Object,
default() {
return {}
},
},
onClick: {
type: Function,
default() {},
},
selection: {
type: Object,
default: () => ([]),
},
},
computed: {
isSelected() {
return this.source.id in this.selection
},
},
}
</script>
<style lang="scss" scoped>
// https://uxplanet.org/7-rules-for-mobile-ui-button-design-e9cf2ea54556
// recommended is 48px
// 44px is what we choose and have very good visual-to-usability ratio
$clickable-area: 44px;
// background icon size
// also used for the scss icon font
$icon-size: 16px;
// icon padding for a $clickable-area width and a $icon-size icon
// ( 44px - 16px ) / 2
$icon-margin: ($clickable-area - $icon-size) / 2;
.entity-picker {
&__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;
}
}
}
&__bubble {
display: flex;
margin-bottom: 4px;
&-checkmark {
display: block;
opacity: 0;
}
&--selected,
&:hover,
&:focus {
.entity-picker__bubble-checkmark {
opacity: 1;
}
::v-deep .user-bubble__content {
// better visual with light default tint
background-color: var(--color-primary-light);
}
}
}
}
::v-deep .user-bubble__content {
// Take full width
width: 100%;
// Override default styling
background: none;
.user-bubble__secondary {
// Force show checkmark
display: inline-flex;
margin-right: 4px;
margin-left: auto;
}
&, * {
// the whole row is clickable,let's force the proper cursor
cursor: pointer;
}
}
</style>

View File

@ -0,0 +1,66 @@
<template>
<EmptyContent class="processing-screen__wrapper" icon="icon-contacts-dark">
<slot />
<template #desc>
<div class="processing-screen__progress">
<progress :max="total" :value="progress" />
</div>
<slot name="desc" />
</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: flex;
width: 80%;
margin: auto;
}
button {
padding: 10px;
height: 44px;
align-self: flex-end;
margin-top: 22px;
min-width: 100px;
}
}
&__progress {
width: 100%;
display: flex;
}
}
</style>

View File

@ -22,7 +22,9 @@
<template>
<div v-if="propModel" class="grid-span-2 property property--without-actions">
<!-- NO title if first element for groups -->
<PropertyTitle
icon="icon-contacts-dark"
:readable-name="t('contacts', 'Groups')" />
<div class="property__row">
<div class="property__label">
@ -60,11 +62,13 @@
import debounce from 'debounce'
import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
import Contact from '../../models/contact'
import PropertyTitle from './PropertyTitle'
export default {
name: 'PropertyGroups',
components: {
PropertyTitle,
Multiselect,
},

View File

@ -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

View File

@ -0,0 +1,26 @@
/**
* @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 { loadState } from '@nextcloud/initial-state'
const contactsinteraction = loadState('contacts', 'contactsinteraction')
export default contactsinteraction

View File

@ -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

View File

@ -105,6 +105,19 @@ const mutations = {
}
})
},
/**
* Add a group
*
* @param {Object} state the store data
* @param {string} groupName the name of the group
*/
addGroup(state, groupName) {
state.groups.push({
name: groupName,
contacts: [],
})
},
}
const getters = {
@ -146,6 +159,16 @@ const actions = {
removeContactToGroup(context, { groupName, contact }) {
context.commit('removeContactToGroup', { groupName, contact })
},
/**
* Add a group
*
* @param {Object} context the store mutations
* @param {string} groupName the name of the group
*/
addGroup(context, groupName) {
context.commit('addGroup', groupName)
},
}
export default { state, mutations, getters, actions }

View File

@ -34,30 +34,100 @@
@click="newContact" />
<!-- groups list -->
<ul v-if="!loading" id="groups-list">
<AppNavigationItem v-for="item in menu"
:key="item.key"
:to="item.router"
:title="item.text"
:icon="item.icon">
<template slot="actions">
<ActionButton v-for="action in item.utils.actions"
:key="action.text"
:icon="action.icon"
@click="action.action">
{{ action.text }}
</ActionButton>
</template>
<template v-if="!loading" #list>
<!-- All contacts group -->
<AppNavigationItem id="everyone"
:title="GROUP_ALL_CONTACTS"
:to="{
name: 'group',
params: { selectedGroup: GROUP_ALL_CONTACTS },
}"
icon="icon-contacts-dark">
<AppNavigationCounter slot="counter">
{{ item.utils.counter }}
{{ sortedContacts.length }}
</AppNavigationCounter>
</AppNavigationItem>
</ul>
<!-- Not grouped group -->
<AppNavigationItem
v-if="ungroupedContacts.length > 0"
id="notgrouped"
:title="GROUP_NO_GROUP_CONTACTS"
:to="{
name: 'group',
params: { selectedGroup: GROUP_NO_GROUP_CONTACTS },
}"
icon="icon-user">
<AppNavigationCounter slot="counter">
{{ ungroupedContacts.length }}
</AppNavigationCounter>
</AppNavigationItem>
<!-- Recently contacted group -->
<AppNavigationItem
v-if="isContactsInteractionEnabled && recentlyContactedContacts && recentlyContactedContacts.contacts.length > 0"
id="recentlycontacted"
:title="t('contactsinteraction', 'Recently contacted')"
:to="{
name: 'group',
params: { selectedGroup: t('contactsinteraction', 'Recently contacted') },
}"
icon="icon-recent-actors">
<AppNavigationCounter slot="counter">
{{ recentlyContactedContacts.contacts.length }}
</AppNavigationCounter>
</AppNavigationItem>
<AppNavigationSpacer />
<!-- Custom groups -->
<AppNavigationItem v-for="group in groupsMenu"
:key="group.key"
:to="group.router"
:title="group.name"
:icon="group.icon">
<template slot="actions">
<ActionButton
icon="icon-add"
@click="addContactsToGroup(group)">
{{ t('contacts', 'Add contacts') }}
</ActionButton>
<ActionButton
icon="icon-download"
@click="downloadGroup(group)">
{{ t('contacts', 'Download') }}
</ActionButton>
</template>
<AppNavigationCounter slot="counter">
{{ group.contacts.length }}
</AppNavigationCounter>
</AppNavigationItem>
<AppNavigationItem
:force-menu="true"
:menu-open.sync="isNewGroupMenuOpen"
:title="t('contacts', '+ New group')"
menu-icon="icon-add"
@click.prevent.stop="toggleNewGroupMenu">
<template slot="actions">
<ActionText :icon="createGroupError ? 'icon-error' : 'icon-contacts-dark'">
{{ createGroupError ? createGroupError : t('contacts', 'Create a new group') }}
</ActionText>
<ActionInput
icon=""
:placeholder="t('contacts','Group name')"
@submit.prevent.stop="createNewGroup" />
</template>
</AppNavigationItem>
</template>
<!-- settings -->
<AppNavigationSettings v-if="!loading">
<SettingsSection />
</AppNavigationSettings>
<template #footer>
<AppNavigationSettings v-if="!loading">
<SettingsSection />
</AppNavigationSettings>
</template>
</AppNavigation>
<AppContent>
@ -70,49 +140,96 @@
<div id="app-content-wrapper">
<!-- contacts list -->
<ContactsList :list="contactsList"
<ContactsList
v-if="!loading"
:list="contactsList"
:contacts="contacts"
:loading="loading"
:search-query="searchQuery" />
:search-query="searchQuery"
@onAddContactsToGroup="addContactsToGroup(selectedGroup)" />
<!-- main contacts details -->
<ContactDetails :loading="loading" :contact-key="selectedContact" />
</div>
</AppContent>
<!-- Import modal -->
<Modal v-if="isImporting"
:clear-view-delay="-1"
:can-close="isImportDone"
@close="closeImport">
<ImportScreen />
</Modal>
<!-- Select contacts group modal -->
<EntityPicker v-if="showContactPicker"
:data-types="pickerTypes"
: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">
{{ processStatus.total === processStatus.progress
? n('contacts',
'{total} contact added to {name}',
'{total} contacts added to {name}',
processStatus.total,
processStatus
)
: n('contacts',
'Adding {total} contact to {name}',
'Adding {total} contacts to {name}',
processStatus.total,
processStatus
) }}
<template #desc>
<button v-if="processStatus.total === processStatus.progress" class="primary processing-screen__button" @click="closeProcess">
{{ t('contacts', 'Close') }}
</button>
</template>
</ProcessingScreen>
</Modal>
</Content>
</template>
<script>
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import ActionInput from '@nextcloud/vue/dist/Components/ActionInput'
import ActionText from '@nextcloud/vue/dist/Components/ActionText'
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 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 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 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'
import isContactsInteractionEnabled from '../services/isContactsInteractionEnabled'
const GROUP_ALL_CONTACTS = t('contacts', 'All contacts')
const GROUP_NO_GROUP_CONTACTS = t('contacts', 'Not grouped')
@ -121,18 +238,23 @@ export default {
name: 'Contacts',
components: {
ActionButton,
ActionInput,
ActionText,
AppContent,
AppNavigation,
AppNavigationItem,
AppNavigationCounter,
AppNavigationItem,
AppNavigationNew,
AppNavigationSettings,
ActionButton,
AppNavigationSpacer,
ContactDetails,
ContactsList,
Content,
EntityPicker,
ImportScreen,
Modal,
ProcessingScreen,
SettingsSection,
},
@ -155,8 +277,33 @@ export default {
data() {
return {
GROUP_ALL_CONTACTS,
GROUP_NO_GROUP_CONTACTS,
isContactsInteractionEnabled,
loading: true,
// Create group
isCreatingGroup: false,
isNewGroupMenuOpen: false,
createGroupError: null,
// Add to group picker
searchQuery: '',
showContactPicker: false,
contactPickerforGroup: null,
pickerTypes: [{
id: 'contact',
label: t('contacts', 'Contacts'),
}],
// Bulk processing
isProcessing: false,
isProcessDone: false,
processStatus: {
total: 0,
progress: 0,
name: '',
},
}
},
@ -221,71 +368,31 @@ export default {
// generate groups menu from groups store
groupsMenu() {
return this.groups.map(group => {
return {
const menu = this.groups.map(group => {
return Object.assign(group, {
id: group.name.replace(' ', '_'),
key: group.name.replace(' ', '_'),
router: {
name: 'group',
params: { selectedGroup: group.name },
},
text: group.name,
utils: {
counter: group.contacts.length,
actions: [
{
icon: 'icon-download',
text: 'Download',
action: () => this.downloadGroup(group),
},
],
},
}
}).sort(function(a, b) {
return parseInt(b.utils.counter) - parseInt(a.utils.counter)
toString: () => group.name,
})
})
},
menu.sort()
// building the main menu
menu() {
return this.groupAllGroup.concat(this.groupNotGrouped.concat(this.groupsMenu))
},
// default group for every contacts
groupAllGroup() {
return [{
id: 'everyone',
key: 'everyone',
icon: 'icon-contacts-dark',
router: {
name: 'group',
params: { selectedGroup: GROUP_ALL_CONTACTS },
},
text: GROUP_ALL_CONTACTS,
utils: {
counter: this.sortedContacts.length,
},
}]
},
// default group for every contacts
groupNotGrouped() {
if (this.ungroupedContacts.length === 0) {
return []
// Find the Recently Contacted group, delete it from array
const recentlyIndex = menu.findIndex(group => group.name === t('contactsinteraction', 'Recently contacted'))
if (recentlyIndex >= 0) {
menu.splice(recentlyIndex, 1)
}
return [{
id: 'notgrouped',
key: 'notgrouped',
icon: 'icon-user',
router: {
name: 'group',
params: { selectedGroup: GROUP_NO_GROUP_CONTACTS },
},
text: GROUP_NO_GROUP_CONTACTS,
utils: {
counter: this.ungroupedContacts.length,
},
}]
return menu
},
// Recently contacted data
recentlyContactedContacts() {
return this.groups.find(group => group.name === t('contactsinteraction', 'Recently contacted'))
},
},
@ -422,9 +529,28 @@ export default {
selectFirstContactIfNone() {
const inList = this.contactsList.findIndex(contact => contact.key === this.selectedContact) > -1
if (this.selectedContact === undefined || !inList) {
// Unknown contact
if (this.selectedContact && !inList) {
OC.Notification.showTemporary(t('contacts', 'Contact not found'))
this.$router.push({
name: 'group',
params: {
selectedGroup: this.selectedGroup,
},
})
}
// Unknown group
if (!this.groups.find(group => group.name === this.selectedGroup)
&& this.GROUP_ALL_CONTACTS !== this.selectedGroup
&& this.GROUP_NO_GROUP_CONTACTS !== this.selectedGroup) {
OC.Notification.showTemporary(t('contacts', 'Group not found'))
this.$router.push({
name: 'root',
})
return
}
if (Object.keys(this.contactsList).length) {
this.$router.push({
name: 'contact',
@ -510,6 +636,125 @@ export default {
this.$store.dispatch('changeStage', 'default')
}
},
toggleNewGroupMenu() {
this.isNewGroupMenuOpen = !this.isNewGroupMenuOpen
},
createNewGroup(e) {
const input = e.target.querySelector('input[type=text]')
const groupName = input.value.trim()
// Check if already exists
if (this.groups.find(group => group.name === groupName)) {
this.createGroupError = t('contacts', 'This group already exists')
return
}
this.createGroupError = null
console.debug('Created new local group', groupName)
this.$store.dispatch('addGroup', groupName)
this.isNewGroupMenuOpen = false
// Select group
this.$router.push({
name: 'contact',
params: {
selectedGroup: groupName,
},
})
},
// Bulk contacts group management handlers
addContactsToGroup(group) {
// Get the full group if we provided the group name only
if (typeof group === 'string') {
group = this.groups.find(a => a.name === group)
if (!group) {
console.error('Cannot add contact to an undefined group', group)
return
}
}
// Init data set
this.pickerData = this.sortedContacts
.map(({ key }) => {
const contact = this.contacts[key]
return {
id: contact.key,
label: contact.displayName,
type: 'contact',
readOnly: contact.addressbook.readOnly,
groups: contact.groups,
}
})
// No read only contacts
.filter(contact => !contact.readOnly)
// No contacts already present in group
.filter(contact => contact.groups.indexOf(group.name) === -1)
this.showContactPicker = true
this.contactPickerforGroup = group
},
onContactPickerClose() {
this.pickerData = []
this.showContactPicker = false
},
onContactPickerPick(selection) {
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
},
},
}
</script>
<style lang="scss" scoped>
#app-content-wrapper {
display: flex;
}
</style>

View File

@ -24,16 +24,17 @@
namespace OCA\Contacts\Controller;
use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Contacts\Service\SocialApiService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IAppManager;
use OCP\IConfig;
use PHPUnit\Framework\MockObject\MockObject;
use OCP\IInitialStateService;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use OCP\IRequest;
use OCP\L10N\IFactory;
use OCA\Contacts\Service\SocialApiService;
use ChristophWurst\Nextcloud\Testing\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
class PageControllerTest extends TestCase {
private $controller;
@ -56,6 +57,9 @@ class PageControllerTest extends TestCase {
/** @var SocialApiService|MockObject*/
private $socialApi;
/** @var IAppManager|MockObject*/
private $appManager;
public function setUp() {
parent::setUp();
@ -65,6 +69,7 @@ class PageControllerTest extends TestCase {
$this->languageFactory = $this->createMock(IFactory::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->socialApi = $this->createMock(SocialApiService::class);
$this->appManager = $this->createMock(IAppManager::class);
$this->controller = new PageController(
$this->request,
@ -72,7 +77,8 @@ class PageControllerTest extends TestCase {
$this->initialStateService,
$this->languageFactory,
$this->userSession,
$this->socialApi
$this->socialApi,
$this->appManager
);
}

View File

@ -50,6 +50,7 @@ module.exports = {
new webpack.DefinePlugin({ appVersion })
],
resolve: {
extensions: ['*', '.js', '.vue']
extensions: ['*', '.js', '.vue'],
symlinks: false,
}
}