Add Status Dashboard

Signed-off-by: Georg Ehrke <developer@georgehrke.com>
This commit is contained in:
Georg Ehrke 2020-08-18 10:54:46 +02:00
parent 03603db486
commit bd6a6cf3bf
No known key found for this signature in database
GPG Key ID: 9D98FD9380A1CB43
20 changed files with 676 additions and 3 deletions

View File

@ -15,6 +15,7 @@ return array(
'OCA\\UserStatus\\Controller\\PredefinedStatusController' => $baseDir . '/../lib/Controller/PredefinedStatusController.php',
'OCA\\UserStatus\\Controller\\StatusesController' => $baseDir . '/../lib/Controller/StatusesController.php',
'OCA\\UserStatus\\Controller\\UserStatusController' => $baseDir . '/../lib/Controller/UserStatusController.php',
'OCA\\UserStatus\\Dashboard\\UserStatusWidget' => $baseDir . '/../lib/Dashboard/UserStatusWidget.php',
'OCA\\UserStatus\\Db\\UserStatus' => $baseDir . '/../lib/Db/UserStatus.php',
'OCA\\UserStatus\\Db\\UserStatusMapper' => $baseDir . '/../lib/Db/UserStatusMapper.php',
'OCA\\UserStatus\\Exception\\InvalidClearAtException' => $baseDir . '/../lib/Exception/InvalidClearAtException.php',

View File

@ -30,6 +30,7 @@ class ComposerStaticInitUserStatus
'OCA\\UserStatus\\Controller\\PredefinedStatusController' => __DIR__ . '/..' . '/../lib/Controller/PredefinedStatusController.php',
'OCA\\UserStatus\\Controller\\StatusesController' => __DIR__ . '/..' . '/../lib/Controller/StatusesController.php',
'OCA\\UserStatus\\Controller\\UserStatusController' => __DIR__ . '/..' . '/../lib/Controller/UserStatusController.php',
'OCA\\UserStatus\\Dashboard\\UserStatusWidget' => __DIR__ . '/..' . '/../lib/Dashboard/UserStatusWidget.php',
'OCA\\UserStatus\\Db\\UserStatus' => __DIR__ . '/..' . '/../lib/Db/UserStatus.php',
'OCA\\UserStatus\\Db\\UserStatusMapper' => __DIR__ . '/..' . '/../lib/Db/UserStatusMapper.php',
'OCA\\UserStatus\\Exception\\InvalidClearAtException' => __DIR__ . '/..' . '/../lib/Exception/InvalidClearAtException.php',

View File

@ -20,6 +20,10 @@
*
*/
.icon-user-status {
@include icon-color('app', 'user_status', $color-black, 1);
}
.icon-user-status-away {
@include icon-color('user-status-away', 'user_status', '#F4A331', 2);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -30,6 +30,7 @@ use OCA\UserStatus\Connector\UserStatusProvider;
use OCA\UserStatus\Listener\BeforeTemplateRenderedListener;
use OCA\UserStatus\Listener\UserDeletedListener;
use OCA\UserStatus\Listener\UserLiveStatusListener;
use OCA\UserStatus\Dashboard\UserStatusWidget;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@ -69,6 +70,9 @@ class Application extends App implements IBootstrap {
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(UserLiveStatusEvent::class, UserLiveStatusListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
// Register the Dashboard panel
$context->registerDashboardWidget(UserStatusWidget::class);
}
public function boot(IBootContext $context): void {

View File

@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Georg Ehrke
*
* @author Georg Ehrke <oc.list@georgehrke.com>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\UserStatus\Dashboard;
use OCA\UserStatus\AppInfo\Application;
use OCA\UserStatus\Db\UserStatus;
use OCA\UserStatus\Service\StatusService;
use OCP\Dashboard\IWidget;
use OCP\IInitialStateService;
use OCP\IL10N;
use OCP\IUserManager;
use OCP\IUserSession;
/**
* Class UserStatusWidget
*
* @package OCA\UserStatus
*/
class UserStatusWidget implements IWidget {
/** @var IL10N */
private $l10n;
/** @var IInitialStateService */
private $initialStateService;
/** @var IUserManager */
private $userManager;
/** @var IUserSession */
private $userSession;
/** @var StatusService */
private $service;
/**
* UserStatusWidget constructor
*
* @param IL10N $l10n
* @param IInitialStateService $initialStateService
* @param IUserManager $userManager
* @param IUserSession $userSession
* @param StatusService $service
*/
public function __construct(IL10N $l10n,
IInitialStateService $initialStateService,
IUserManager $userManager,
IUserSession $userSession,
StatusService $service) {
$this->l10n = $l10n;
$this->initialStateService = $initialStateService;
$this->userManager = $userManager;
$this->userSession = $userSession;
$this->service = $service;
}
/**
* @inheritDoc
*/
public function getId(): string {
return Application::APP_ID;
}
/**
* @inheritDoc
*/
public function getTitle(): string {
return $this->l10n->t('Recent statuses');
}
/**
* @inheritDoc
*/
public function getOrder(): int {
return 5;
}
/**
* @inheritDoc
*/
public function getIconClass(): string {
return 'icon-user-status';
}
/**
* @inheritDoc
*/
public function getUrl(): ?string {
return null;
}
/**
* @inheritDoc
*/
public function load(): void {
\OCP\Util::addScript(Application::APP_ID, 'dashboard');
$currentUser = $this->userSession->getUser();
if ($currentUser === null) {
$this->initialStateService->provideInitialState(Application::APP_ID, 'dashboard_data', []);
return;
}
$currentUserId = $currentUser->getUID();
// Fetch status updates and filter current user
$recentStatusUpdates = array_slice(
array_filter(
$this->service->findAllRecentStatusChanges(8, 0),
static function (UserStatus $status) use ($currentUserId): bool {
return $status->getUserId() !== $currentUserId;
}
),
0,
7
);
$this->initialStateService->provideInitialState(Application::APP_ID, 'dashboard_data', array_map(function (UserStatus $status): array {
$user = $this->userManager->get($status->getUserId());
$displayName = $status->getUserId();
if ($user !== null) {
$displayName = $user->getDisplayName();
}
return [
'userId' => $status->getUserId(),
'displayName' => $displayName,
'status' => $status->getStatus() === 'invisible' ? 'offline' : $status->getStatus(),
'icon' => $status->getCustomIcon(),
'message' => $status->getCustomMessage(),
'timestamp' => $status->getStatusTimestamp(),
];
}, $recentStatusUpdates));
}
}

View File

@ -69,6 +69,33 @@ class UserStatusMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* @param int|null $limit
* @param int|null $offset
* @return array
*/
public function findAllRecent(?int $limit = null, ?int $offset = null): array {
$qb = $this->db->getQueryBuilder();
$qb
->select('*')
->from($this->tableName)
->orderBy('status_timestamp', 'DESC')
->where($qb->expr()->notIn('status', $qb->createNamedParameter(['online', 'away'], IQueryBuilder::PARAM_STR_ARRAY)))
->orWhere($qb->expr()->isNotNull('message_id'))
->orWhere($qb->expr()->isNotNull('custom_icon'))
->orWhere($qb->expr()->isNotNull('custom_message'));
if ($limit !== null) {
$qb->setMaxResults($limit);
}
if ($offset !== null) {
$qb->setFirstResult($offset);
}
return $this->findEntities($qb);
}
/**
* @param string $userId
* @return UserStatus

View File

@ -95,6 +95,17 @@ class StatusService {
}, $this->mapper->findAll($limit, $offset));
}
/**
* @param int|null $limit
* @param int|null $offset
* @return array
*/
public function findAllRecentStatusChanges(?int $limit = null, ?int $offset = null): array {
return array_map(function ($status) {
return $this->processStatus($status);
}, $this->mapper->findAllRecent($limit, $offset));
}
/**
* @param string $userId
* @return UserStatus

View File

@ -0,0 +1,48 @@
/**
* @copyright Copyright (c) 2020 Georg Ehrke
*
* @author Georg Ehrke <oc.list@georgehrke.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 Vue from 'vue'
import { generateFilePath } from '@nextcloud/router'
import { getRequestToken } from '@nextcloud/auth'
import { translate, translatePlural } from '@nextcloud/l10n'
import Dashboard from './views/Dashboard'
// eslint-disable-next-line
__webpack_nonce__ = btoa(getRequestToken())
// eslint-disable-next-line
__webpack_public_path__ = generateFilePath('user_status', '', 'js/')
Vue.prototype.t = translate
Vue.prototype.n = translatePlural
Vue.prototype.OC = OC
Vue.prototype.OCA = OCA
document.addEventListener('DOMContentLoaded', function() {
OCA.Dashboard.register('user_status', (el) => {
const View = Vue.extend(Dashboard)
new View({
propsData: {},
}).$mount(el)
})
})

View File

@ -1,3 +1,24 @@
/**
* @copyright Copyright (c) 2020 Georg Ehrke
*
* @author Georg Ehrke <oc.list@georgehrke.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 Vue from 'vue'
import { getRequestToken } from '@nextcloud/auth'
import App from './App'

View File

@ -0,0 +1,100 @@
<!--
- @copyright Copyright (c) 2020 Georg Ehrke <oc.list@georgehrke.com>
- @author Georg Ehrke <oc.list@georgehrke.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>
<DashboardWidget
id="user-status_panel"
:items="items"
:loading="loading">
<template v-slot:empty-content>
<EmptyContent
id="user_status-widget-empty-content"
icon="icon-user-status">
{{ t('user_status', 'No recent status changes') }}
</EmptyContent>
</template>
</DashboardWidget>
</template>
<script>
import { DashboardWidget } from '@nextcloud/vue-dashboard'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import { loadState } from '@nextcloud/initial-state'
import moment from '@nextcloud/moment'
export default {
name: 'Dashboard',
components: {
DashboardWidget,
EmptyContent,
},
data() {
return {
statuses: [],
loading: true,
}
},
computed: {
items() {
return this.statuses.map((item) => {
const icon = item.icon || ''
const message = item.message || ''
const status = `${icon} ${message}`
let subText
if (item.icon === null && item.message === null && item.timestamp === null) {
subText = ''
} else if (item.icon === null && item.message === null && item.timestamp !== null) {
subText = moment(item.timestamp, 'X').fromNow()
} else if (item.timestamp !== null) {
subText = this.t('user_status', '{status}, {timestamp}', {
status,
timestamp: moment(item.timestamp, 'X').fromNow(),
})
} else {
subText = status
}
return {
mainText: item.displayName,
subText,
avatarUsername: item.userId,
}
})
},
},
mounted() {
try {
this.statuses = loadState('user_status', 'dashboard_data')
this.loading = false
} catch (e) {
console.error(e)
}
},
}
</script>
<style lang="scss">
#user_status-widget-empty-content {
text-align: center;
margin-top: 5vh;
}
</style>

View File

@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Georg Ehrke
*
* @author Georg Ehrke <oc.list@georgehrke.com>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\UserStatus\Tests\Dashboard;
use OCA\UserStatus\Dashboard\UserStatusWidget;
use OCA\UserStatus\Db\UserStatus;
use OCA\UserStatus\Service\StatusService;
use OCP\IInitialStateService;
use OCP\IL10N;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use Test\TestCase;
class UserStatusWidgetTest extends TestCase {
/** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */
private $l10n;
/** @var IInitialStateService|\PHPUnit\Framework\MockObject\MockObject */
private $initialState;
/** @var IUserManager|\PHPUnit\Framework\MockObject\MockObject */
private $userManager;
/** @var IUserSession|\PHPUnit\Framework\MockObject\MockObject */
private $userSession;
/** @var StatusService|\PHPUnit\Framework\MockObject\MockObject */
private $service;
/** @var UserStatusWidget */
private $widget;
protected function setUp(): void {
parent::setUp();
$this->l10n = $this->createMock(IL10N::class);
$this->initialState = $this->createMock(IInitialStateService::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->service = $this->createMock(StatusService::class);
$this->widget = new UserStatusWidget($this->l10n, $this->initialState, $this->userManager, $this->userSession, $this->service);
}
public function testGetId(): void {
$this->assertEquals('user_status', $this->widget->getId());
}
public function testGetTitle(): void {
$this->l10n->expects($this->exactly(1))
->method('t')
->willReturnArgument(0);
$this->assertEquals('Recent statuses', $this->widget->getTitle());
}
public function testGetOrder(): void {
$this->assertEquals(5, $this->widget->getOrder());
}
public function testGetIconClass(): void {
$this->assertEquals('icon-user-status', $this->widget->getIconClass());
}
public function testGetUrl(): void {
$this->assertNull($this->widget->getUrl());
}
public function testLoadNoUserSession(): void {
$this->userSession->expects($this->once())
->method('getUser')
->willReturn(null);
$this->initialState->expects($this->once())
->method('provideInitialState')
->with('user_status', 'dashboard_data', []);
$this->service->expects($this->never())
->method('findAllRecentStatusChanges');
$this->widget->load();
}
public function testLoadWithCurrentUser(): void {
$user = $this->createMock(IUser::class);
$user->method('getUid')->willReturn('john.doe');
$this->userSession->expects($this->once())
->method('getUser')
->willReturn($user);
$user1 = $this->createMock(IUser::class);
$user1->method('getDisplayName')->willReturn('User No. 1');
$this->userManager
->method('get')
->willReturnMap([
['user_1', $user1],
['user_2', null],
['user_3', null],
['user_4', null],
['user_5', null],
['user_6', null],
['user_7', null],
]);
$userStatuses = [
UserStatus::fromParams([
'userId' => 'user_1',
'status' => 'online',
'customIcon' => '💻',
'customMessage' => 'Working',
'statusTimestamp' => 5000,
]),
UserStatus::fromParams([
'userId' => 'user_2',
'status' => 'away',
'customIcon' => '☕️',
'customMessage' => 'Office Hangout',
'statusTimestamp' => 6000,
]),
UserStatus::fromParams([
'userId' => 'user_3',
'status' => 'dnd',
'customIcon' => null,
'customMessage' => null,
'statusTimestamp' => 7000,
]),
UserStatus::fromParams([
'userId' => 'john.doe',
'status' => 'away',
'customIcon' => '☕️',
'customMessage' => 'Office Hangout',
'statusTimestamp' => 90000,
]),
UserStatus::fromParams([
'userId' => 'user_4',
'status' => 'dnd',
'customIcon' => null,
'customMessage' => null,
'statusTimestamp' => 7000,
]),
UserStatus::fromParams([
'userId' => 'user_5',
'status' => 'invisible',
'customIcon' => '🏝',
'customMessage' => 'On vacation',
'statusTimestamp' => 7000,
]),
UserStatus::fromParams([
'userId' => 'user_6',
'status' => 'offline',
'customIcon' => null,
'customMessage' => null,
'statusTimestamp' => 7000,
]),
UserStatus::fromParams([
'userId' => 'user_7',
'status' => 'invisible',
'customIcon' => null,
'customMessage' => null,
'statusTimestamp' => 7000,
]),
];
$this->service->expects($this->once())
->method('findAllRecentStatusChanges')
->with(8, 0)
->willReturn($userStatuses);
$this->initialState->expects($this->once())
->method('provideInitialState')
->with('user_status', 'dashboard_data', $this->callback(function ($data): bool {
$this->assertEquals([
[
'userId' => 'user_1',
'displayName' => 'User No. 1',
'status' => 'online',
'icon' => '💻',
'message' => 'Working',
'timestamp' => 5000,
],
[
'userId' => 'user_2',
'displayName' => 'user_2',
'status' => 'away',
'icon' => '☕️',
'message' => 'Office Hangout',
'timestamp' => 6000,
],
[
'userId' => 'user_3',
'displayName' => 'user_3',
'status' => 'dnd',
'icon' => null,
'message' => null,
'timestamp' => 7000,
],
[
'userId' => 'user_4',
'displayName' => 'user_4',
'status' => 'dnd',
'icon' => null,
'message' => null,
'timestamp' => 7000,
],
[
'userId' => 'user_5',
'displayName' => 'user_5',
'status' => 'offline',
'icon' => '🏝',
'message' => 'On vacation',
'timestamp' => 7000,
],
[
'userId' => 'user_6',
'displayName' => 'user_6',
'status' => 'offline',
'icon' => null,
'message' => null,
'timestamp' => 7000,
],
[
'userId' => 'user_7',
'displayName' => 'user_7',
'status' => 'offline',
'icon' => null,
'message' => null,
'timestamp' => 7000,
],
], $data);
return true;
}));
$this->widget->load();
}
}

View File

@ -65,6 +65,15 @@ class UserStatusMapperTest extends TestCase {
$this->assertEquals('user2', $offsetResults[0]->getUserId());
}
public function testFindAllRecent(): void {
$this->insertSampleStatuses();
$allResults = $this->mapper->findAllRecent(2, 0);
$this->assertCount(2, $allResults);
$this->assertEquals('user1', $allResults[0]->getUserId());
$this->assertEquals('user2', $allResults[1]->getUserId());
}
public function testGetFind(): void {
$this->insertSampleStatuses();

View File

@ -84,6 +84,21 @@ class StatusServiceTest extends TestCase {
], $this->service->findAll(20, 50));
}
public function testFindAllRecentStatusChanges(): void {
$status1 = $this->createMock(UserStatus::class);
$status2 = $this->createMock(UserStatus::class);
$this->mapper->expects($this->once())
->method('findAllRecent')
->with(20, 50)
->willReturn([$status1, $status2]);
$this->assertEquals([
$status1,
$status2,
], $this->service->findAllRecentStatusChanges(20, 50));
}
public function testFindByUserId(): void {
$status = $this->createMock(UserStatus::class);
$this->mapper->expects($this->once())

View File

@ -2,6 +2,7 @@ const path = require('path')
module.exports = {
entry: {
'dashboard': path.join(__dirname, 'src', 'dashboard'),
'user-status-menu': path.join(__dirname, 'src', 'main-user-status-menu')
},
output: {

10
package-lock.json generated
View File

@ -1684,6 +1684,16 @@
}
}
},
"@nextcloud/vue-dashboard": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@nextcloud/vue-dashboard/-/vue-dashboard-0.1.3.tgz",
"integrity": "sha512-7b02zkarX7b18IRQmZEW1NM+dvtcUih2M0+CZyuQfcvfyMQudOz+BdA/oD1p7PmdBds1IR8OvY1+CnpmgAzfQg==",
"requires": {
"@nextcloud/vue": "^2.3.0",
"core-js": "^3.6.4",
"vue": "^2.6.11"
}
},
"@nodelib/fs.scandir": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz",

View File

@ -40,6 +40,7 @@
"@nextcloud/paths": "^1.1.2",
"@nextcloud/router": "^1.1.0",
"@nextcloud/vue": "^2.3.0",
"@nextcloud/vue-dashboard": "^0.1.3",
"autosize": "^4.0.2",
"backbone": "^1.4.0",
"blueimp-md5": "^2.16.0",