Merge pull request #1687 from nextcloud/enh/add-virtual-group
This commit is contained in:
commit
87615fb654
|
@ -24,3 +24,4 @@
|
|||
use OCA\Contacts\AppInfo\Application;
|
||||
|
||||
$app = \OC::$server->query(Application::class);
|
||||
$app->register();
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 |
|
@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.'
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.'),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||
},
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -50,6 +50,7 @@ module.exports = {
|
|||
new webpack.DefinePlugin({ appVersion })
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['*', '.js', '.vue']
|
||||
extensions: ['*', '.js', '.vue'],
|
||||
symlinks: false,
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue