feat(dashboard): implement widget item api v2

This API enables the dashboard to render all widgets from the API data
alone without having apps to provide their own bundles. This saves a lot
of traffic and execution time as a lot less javascript has to be parsed
on the frontend.

Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
This commit is contained in:
Richard Steinmetz 2023-08-17 15:09:30 +02:00 committed by Joas Schilling
parent 82835eaa46
commit 6982597b6a
No known key found for this signature in database
GPG Key ID: 74434EFE0D2E2205
17 changed files with 667 additions and 376 deletions

View File

@ -7,6 +7,7 @@ declare(strict_types=1);
*
* @author Julien Veyssier <eneiluj@posteo.net>
* @author Julius Härtl <jus@bitgrid.net>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
@ -33,5 +34,6 @@ return [
'ocs' => [
['name' => 'dashboardApi#getWidgets', 'url' => '/api/v1/widgets', 'verb' => 'GET'],
['name' => 'dashboardApi#getWidgetItems', 'url' => '/api/v1/widget-items', 'verb' => 'GET'],
['name' => 'dashboardApi#getWidgetItemsV2', 'url' => '/api/v2/widget-items', 'verb' => 'GET'],
]
];

View File

@ -7,6 +7,7 @@ declare(strict_types=1);
*
* @author Julien Veyssier <eneiluj@posteo.net>
* @author Kate Döen <kate.doeen@nextcloud.com>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
@ -35,6 +36,7 @@ use OCP\Dashboard\IButtonWidget;
use OCP\Dashboard\IIconWidget;
use OCP\Dashboard\IOptionWidget;
use OCP\Dashboard\IManager;
use OCP\Dashboard\IReloadableWidget;
use OCP\Dashboard\IWidget;
use OCP\Dashboard\Model\WidgetButton;
use OCP\Dashboard\Model\WidgetOptions;
@ -42,11 +44,14 @@ use OCP\IConfig;
use OCP\IRequest;
use OCP\Dashboard\IAPIWidget;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\Dashboard\Model\WidgetItem;
use OCP\Dashboard\Model\WidgetItems;
/**
* @psalm-import-type DashboardWidget from ResponseDefinitions
* @psalm-import-type DashboardWidgetItem from ResponseDefinitions
* @psalm-import-type DashboardWidgetItems from ResponseDefinitions
*/
class DashboardApiController extends OCSController {
@ -71,6 +76,24 @@ class DashboardApiController extends OCSController {
$this->userId = $userId;
}
/**
* @param string[] $widgetIds Limit widgets to given ids
* @return IWidget[]
*/
private function getShownWidgets(array $widgetIds): array {
if (empty($widgetIds)) {
$systemDefault = $this->config->getAppValue('dashboard', 'layout', 'recommendations,spreed,mail,calendar');
$widgetIds = explode(',', $this->config->getUserValue($this->userId, 'dashboard', 'layout', $systemDefault));
}
return array_filter(
$this->dashboardManager->getWidgets(),
static function (IWidget $widget) use ($widgetIds) {
return in_array($widget->getId(), $widgetIds);
},
);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
@ -83,18 +106,11 @@ class DashboardApiController extends OCSController {
* @return DataResponse<Http::STATUS_OK, array<string, DashboardWidgetItem[]>, array{}>
*/
public function getWidgetItems(array $sinceIds = [], int $limit = 7, array $widgets = []): DataResponse {
$showWidgets = $widgets;
$items = [];
if (empty($showWidgets)) {
$systemDefault = $this->config->getAppValue('dashboard', 'layout', 'recommendations,spreed,mail,calendar');
$showWidgets = explode(',', $this->config->getUserValue($this->userId, 'dashboard', 'layout', $systemDefault));
}
$widgets = $this->dashboardManager->getWidgets();
$widgets = $this->getShownWidgets($widgets);
foreach ($widgets as $widget) {
if ($widget instanceof IAPIWidget && in_array($widget->getId(), $showWidgets)) {
$items[$widget->getId()] = array_map(function (WidgetItem $item) {
if ($widget instanceof IAPIWidget) {
$items[$widget->getId()] = array_map(static function (WidgetItem $item) {
return $item->jsonSerialize();
}, $widget->getItems($this->userId, $sinceIds[$widget->getId()] ?? null, $limit));
}
@ -103,6 +119,31 @@ class DashboardApiController extends OCSController {
return new DataResponse($items);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* Get the items for the widgets
*
* @param array<string, string> $sinceIds Array indexed by widget Ids, contains date/id from which we want the new items
* @param int $limit Limit number of result items per widget
* @param string[] $widgets Limit results to specific widgets
* @return DataResponse<Http::STATUS_OK, array<string, DashboardWidgetItems>, array{}>
*/
public function getWidgetItemsV2(array $sinceIds = [], int $limit = 7, array $widgets = []): DataResponse {
$items = [];
$widgets = $this->getShownWidgets($widgets);
foreach ($widgets as $widget) {
if ($widget instanceof IAPIWidgetV2) {
$items[$widget->getId()] = $widget
->getItemsV2($this->userId, $sinceIds[$widget->getId()] ?? null, $limit)
->jsonSerialize();
}
}
return new DataResponse($items);
}
/**
* Get the widgets
*
@ -124,6 +165,8 @@ class DashboardApiController extends OCSController {
'icon_url' => ($widget instanceof IIconWidget) ? $widget->getIconUrl() : '',
'widget_url' => $widget->getUrl(),
'item_icons_round' => $options->withRoundItemIcons(),
'item_api_versions' => [],
'reload_interval' => 0,
];
if ($widget instanceof IButtonWidget) {
$data += [
@ -136,6 +179,15 @@ class DashboardApiController extends OCSController {
}, $widget->getWidgetButtons($this->userId)),
];
}
if ($widget instanceof IReloadableWidget) {
$data['reload_interval'] = $widget->getReloadInterval();
}
if ($widget instanceof IAPIWidget) {
$data['item_api_versions'][] = 1;
}
if ($widget instanceof IAPIWidgetV2) {
$data['item_api_versions'][] = 2;
}
return $data;
}, $widgets);

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
* @copyright Copyright (c) 2023 Kate Döen <kate.doeen@nextcloud.com>
*
* @author Kate Döen <kate.doeen@nextcloud.com>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
@ -34,6 +35,8 @@ namespace OCA\Dashboard;
* icon_url: string,
* widget_url: ?string,
* item_icons_round: bool,
* item_api_versions: int[],
* reload_interval: int,
* buttons?: array{
* type: string,
* text: string,
@ -46,8 +49,15 @@ namespace OCA\Dashboard;
* title: string,
* link: string,
* iconUrl: string,
* overlayIconUrl: string,
* sinceId: string,
* }
*
* @psalm-type DashboardWidgetItems = array{
* items: DashboardWidgetItem[],
* emptyContentMessage: string,
* halfEmptyContentMessage: string,
* }
*/
class ResponseDefinitions {
}

View File

@ -53,7 +53,9 @@
"icon_class",
"icon_url",
"widget_url",
"item_icons_round"
"item_icons_round",
"item_api_versions",
"reload_interval"
],
"properties": {
"id": {
@ -79,6 +81,17 @@
"item_icons_round": {
"type": "boolean"
},
"item_api_versions": {
"type": "array",
"items": {
"type": "integer",
"format": "int64"
}
},
"reload_interval": {
"type": "integer",
"format": "int64"
},
"buttons": {
"type": "array",
"items": {
@ -110,6 +123,7 @@
"title",
"link",
"iconUrl",
"overlayIconUrl",
"sinceId"
],
"properties": {
@ -125,10 +139,35 @@
"iconUrl": {
"type": "string"
},
"overlayIconUrl": {
"type": "string"
},
"sinceId": {
"type": "string"
}
}
},
"WidgetItems": {
"type": "object",
"required": [
"items",
"emptyContentMessage",
"halfEmptyContentMessage"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WidgetItem"
}
},
"emptyContentMessage": {
"type": "string"
},
"halfEmptyContentMessage": {
"type": "string"
}
}
}
}
},
@ -291,6 +330,99 @@
}
}
}
},
"/ocs/v2.php/apps/dashboard/api/v2/widget-items": {
"get": {
"operationId": "dashboard_api-get-widget-items-v2",
"summary": "Get the items for the widgets",
"tags": [
"dashboard_api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "sinceIds",
"in": "query",
"description": "Array indexed by widget Ids, contains date/id from which we want the new items",
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"description": "Limit number of result items per widget",
"schema": {
"type": "integer",
"format": "int64",
"default": 7
}
},
{
"name": "widgets[]",
"in": "query",
"description": "Limit results to specific widgets",
"schema": {
"type": "array",
"default": [],
"items": {
"type": "string"
}
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"required": true,
"schema": {
"type": "string",
"default": "true"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/WidgetItems"
}
}
}
}
}
}
}
}
}
}
}
}
},
"tags": []

View File

@ -14,21 +14,44 @@
v-bind="{swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3}"
handle=".panel--header"
@end="saveLayout">
<div v-for="panelId in layout" :key="panels[panelId].id" class="panel">
<div class="panel--header">
<h2>
<div aria-labelledby="panel--header--icon--description"
aria-hidden="true"
:class="panels[panelId].iconClass"
role="img" />
{{ panels[panelId].title }}
</h2>
<span id="panel--header--icon--description" class="hidden-visually"> {{ t('dashboard', '"{title} icon"', { title: panels[panelId].title }) }} </span>
<template v-for="panelId in layout">
<div v-if="isApiWidgetV2(panels[panelId].id)"
:key="`${panels[panelId].id}-v2`"
class="panel">
<div class="panel--header">
<h2>
<div aria-labelledby="panel--header--icon--description"
aria-hidden="true"
:class="apiWidgets[panels[panelId].id].icon_class"
role="img" />
{{ apiWidgets[panels[panelId].id].title }}
</h2>
<span id="panel--header--icon--description" class="hidden-visually">
{{ t('dashboard', '"{title} icon"', { title: apiWidgets[panels[panelId].id].title }) }}
</span>
</div>
<div class="panel--content">
<ApiDashboardWidget :widget="apiWidgets[panels[panelId].id]"
:data="apiWidgetItems[panels[panelId].id]"
:loading="loadingItems" />
</div>
</div>
<div class="panel--content" :class="{ loading: !panels[panelId].mounted }">
<div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
<div v-else :key="panels[panelId].id" class="panel">
<div class="panel--header">
<h2>
<div aria-labelledby="panel--header--icon--description"
aria-hidden="true"
:class="panels[panelId].iconClass"
role="img" />
{{ panels[panelId].title }}
</h2>
<span id="panel--header--icon--description" class="hidden-visually"> {{ t('dashboard', '"{title} icon"', { title: panels[panelId].title }) }} </span>
</div>
<div class="panel--content" :class="{ loading: !panels[panelId].mounted }">
<div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
</div>
</div>
</div>
</template>
</Draggable>
<div class="footer">
@ -94,7 +117,7 @@
</template>
<script>
import { generateUrl } from '@nextcloud/router'
import { generateUrl, generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
@ -105,6 +128,7 @@ import Pencil from 'vue-material-design-icons/Pencil.vue'
import Vue from 'vue'
import isMobile from './mixins/isMobile.js'
import ApiDashboardWidget from './components/ApiDashboardWidget.vue'
const panels = loadState('dashboard', 'panels')
const firstRun = loadState('dashboard', 'firstRun')
@ -123,6 +147,7 @@ const statusInfo = {
export default {
name: 'DashboardApp',
components: {
ApiDashboardWidget,
NcButton,
Draggable,
NcModal,
@ -150,6 +175,9 @@ export default {
modal: false,
appStoreUrl: generateUrl('/settings/apps/dashboard'),
statuses: {},
apiWidgets: [],
apiWidgetItems: {},
loadingItems: true,
}
},
computed: {
@ -239,6 +267,23 @@ export default {
},
},
async created() {
await this.fetchApiWidgets()
const apiWidgetIdsToFetch = Object
.values(this.apiWidgets)
.filter(widget => this.isApiWidgetV2(widget.id))
.map(widget => widget.id)
await Promise.all(apiWidgetIdsToFetch.map(id => this.fetchApiWidgetItems([id], true)))
for (const widget of Object.values(this.apiWidgets)) {
if (widget.reload_interval > 0) {
setInterval(async () => {
await this.fetchApiWidgetItems([widget.id], true)
}, widget.reload_interval * 1000)
}
}
},
mounted() {
this.updateSkipLink()
window.addEventListener('scroll', this.handleScroll)
@ -278,6 +323,11 @@ export default {
},
rerenderPanels() {
for (const app in this.callbacks) {
// TODO: Properly rerender v2 widgets
if (this.isApiWidgetV2(this.panels[app].id)) {
continue
}
const element = this.$refs[app]
if (this.layout.indexOf(app) === -1) {
continue
@ -374,6 +424,33 @@ export default {
document.body.classList.remove('dashboard--scrolled')
}
},
async fetchApiWidgets() {
const response = await axios.get(generateOcsUrl('/apps/dashboard/api/v1/widgets'))
this.apiWidgets = response.data.ocs.data
},
async fetchApiWidgetItems(widgetIds, merge = false) {
try {
const url = generateOcsUrl('/apps/dashboard/api/v2/widget-items')
const params = new URLSearchParams(widgetIds.map(id => ['widgets[]', id]))
const response = await axios.get(`${url}?${params.toString()}`)
const widgetItems = response.data.ocs.data
if (merge) {
this.apiWidgetItems = Object.assign({}, this.apiWidgetItems, widgetItems)
} else {
this.apiWidgetItems = widgetItems
}
} finally {
this.loadingItems = false
}
},
isApiWidgetV2(id) {
for (const widget of Object.values(this.apiWidgets)) {
if (widget.id === id && widget.item_api_versions.includes(2)) {
return true
}
}
return false
},
},
}
</script>
@ -470,6 +547,7 @@ export default {
margin-right: 16px;
background-position: center;
float: left;
margin-top: -6px;
}
}
}

View File

@ -0,0 +1,140 @@
<!--
- @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
-
- @author Richard Steinmetz <richard@steinmetz.cloud>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU 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 General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<NcDashboardWidget :items="items"
:show-more-label="showMoreLabel"
:show-more-url="showMoreUrl"
:loading="loading"
:show-items-and-empty-content="!!halfEmptyContentMessage"
:half-empty-content-message="halfEmptyContentMessage">
<template #default="{ item }">
<NcDashboardWidgetItem :target-url="item.link"
:overlay-icon-url="item.overlayIconUrl ? item.overlayIconUrl : ''"
:main-text="item.title"
:sub-text="item.subtitle">
<template #avatar>
<template v-if="item.iconUrl">
<NcAvatar :size="44" :url="item.iconUrl" />
</template>
</template>
</NcDashboardWidgetItem>
</template>
<template #empty-content>
<NcEmptyContent v-if="items.length === 0"
:description="emptyContentMessage">
<template #icon>
<CheckIcon v-if="emptyContentMessage" :size="65" />
</template>
<template #action>
<NcButton v-if="setupButton" :href="setupButton.link">
{{ setupButton.text }}
</NcButton>
</template>
</NcEmptyContent>
</template>
</NcDashboardWidget>
</template>
<script>
import {
NcAvatar,
NcDashboardWidget,
NcDashboardWidgetItem,
NcEmptyContent,
NcButton,
} from '@nextcloud/vue'
import CheckIcon from 'vue-material-design-icons/Check.vue'
export default {
name: 'ApiDashboardWidget',
components: {
NcAvatar,
NcDashboardWidget,
NcDashboardWidgetItem,
NcEmptyContent,
NcButton,
CheckIcon,
},
props: {
widget: {
type: [Object, undefined],
default: undefined,
},
data: {
type: [Object, undefined],
default: undefined,
},
loading: {
type: Boolean,
required: true,
},
},
computed: {
/** @return {object[]} */
items() {
return this.data?.items ?? []
},
/** @return {string} */
emptyContentMessage() {
return this.data?.emptyContentMessage ?? ''
},
/** @return {string} */
halfEmptyContentMessage() {
return this.data?.halfEmptyContentMessage ?? ''
},
/** @return {object|undefined} */
newButton() {
// TODO: Render new button in the template
// I couldn't find a widget that makes use of the button. Furthermore, there is no convenient
// way to render such a button using the official widget component.
return this.widget?.buttons?.find(button => button.type === 'new')
},
/** @return {object|undefined} */
moreButton() {
return this.widget?.buttons?.find(button => button.type === 'more')
},
/** @return {object|undefined} */
setupButton() {
return this.widget?.buttons?.find(button => button.type === 'setup')
},
/** @return {string|undefined} */
showMoreLabel() {
return this.moreButton?.text
},
/** @return {string|undefined} */
showMoreUrl() {
return this.moreButton?.link
},
},
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -6,6 +6,7 @@ declare(strict_types=1);
* @copyright Copyright (c) 2020, Georg Ehrke
*
* @author Georg Ehrke <oc.list@georgehrke.com>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
@ -31,9 +32,11 @@ use OCA\UserStatus\Service\StatusService;
use OCP\AppFramework\Services\IInitialState;
use OCP\Dashboard\IAPIWidget;
use OCP\Dashboard\IButtonWidget;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\Dashboard\IIconWidget;
use OCP\Dashboard\IOptionWidget;
use OCP\Dashboard\Model\WidgetItem;
use OCP\Dashboard\Model\WidgetItems;
use OCP\Dashboard\Model\WidgetOptions;
use OCP\IDateTimeFormatter;
use OCP\IL10N;
@ -48,7 +51,7 @@ use OCP\Util;
*
* @package OCA\UserStatus
*/
class UserStatusWidget implements IAPIWidget, IIconWidget, IOptionWidget {
class UserStatusWidget implements IAPIWidget, IAPIWidgetV2, IIconWidget, IOptionWidget {
private IL10N $l10n;
private IDateTimeFormatter $dateTimeFormatter;
private IURLGenerator $urlGenerator;
@ -132,17 +135,6 @@ class UserStatusWidget implements IAPIWidget, IIconWidget, IOptionWidget {
* @inheritDoc
*/
public function load(): void {
Util::addScript(Application::APP_ID, 'dashboard');
$currentUser = $this->userSession->getUser();
if ($currentUser === null) {
$this->initialStateService->provideInitialState('dashboard_data', []);
return;
}
$currentUserId = $currentUser->getUID();
$widgetItemsData = $this->getWidgetData($currentUserId);
$this->initialStateService->provideInitialState('dashboard_data', $widgetItemsData);
}
private function getWidgetData(string $userId, ?string $since = null, int $limit = 7): array {
@ -201,6 +193,17 @@ class UserStatusWidget implements IAPIWidget, IIconWidget, IOptionWidget {
}, $widgetItemsData);
}
/**
* @inheritDoc
*/
public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
$items = $this->getItems($userId, $since, $limit);
return new WidgetItems(
$items,
count($items) === 0 ? $this->l10n->t('No recent status changes') : '',
);
}
public function getWidgetOptions(): WidgetOptions {
return new WidgetOptions(true);
}

View File

@ -1,44 +0,0 @@
/**
* @copyright Copyright (c) 2020 Georg Ehrke
*
* @author Georg Ehrke <oc.list@georgehrke.com>
*
* @license AGPL-3.0-or-later
*
* 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 { translate, translatePlural } from '@nextcloud/l10n'
import Dashboard from './views/Dashboard.vue'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())
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,121 +0,0 @@
<!--
- @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>
<NcDashboardWidget id="user-status_panel"
:items="items"
:loading="loading"
:empty-content-message="t('user_status', 'No recent status changes')">
<template #default="{ item }">
<NcDashboardWidgetItem :main-text="item.mainText"
:sub-text="item.subText">
<template #avatar>
<NcAvatar class="item-avatar"
:size="44"
:user="item.avatarUsername"
:display-name="item.mainText"
:show-user-status="false"
:show-user-status-compact="false" />
</template>
</NcDashboardWidgetItem>
</template>
<template #emptyContentIcon>
<div class="icon-user-status-dark" />
</template>
</NcDashboardWidget>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import NcDashboardWidget from '@nextcloud/vue/dist/Components/NcDashboardWidget.js'
import NcDashboardWidgetItem from '@nextcloud/vue/dist/Components/NcDashboardWidgetItem.js'
import moment from '@nextcloud/moment'
export default {
name: 'Dashboard',
components: {
NcAvatar,
NcDashboardWidget,
NcDashboardWidgetItem,
},
data() {
return {
statuses: [],
loading: true,
}
},
computed: {
items() {
return this.statuses.map((item) => {
const icon = item.icon || ''
let message = item.message || ''
if (message === '') {
if (item.status === 'away') {
message = t('user_status', 'Away')
}
if (item.status === 'dnd') {
message = t('user_status', 'Do not disturb')
}
}
const status = item.icon !== '' ? `${icon} ${message}` : message
let subText
if (item.icon === null && message === '' && item.timestamp === null) {
subText = ''
} else if (item.icon === null && message === '' && 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(),
}, null, { escape: false, sanitize: false })
} 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">
.icon-user-status-dark {
width: 64px;
height: 64px;
background-size: 64px;
filter: var(--background-invert-if-dark);
}
</style>

View File

@ -7,6 +7,7 @@ declare(strict_types=1);
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Georg Ehrke <oc.list@georgehrke.com>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
@ -27,13 +28,11 @@ declare(strict_types=1);
namespace OCA\UserStatus\Tests\Dashboard;
use OCA\UserStatus\Dashboard\UserStatusWidget;
use OCA\UserStatus\Db\UserStatus;
use OCA\UserStatus\Service\StatusService;
use OCP\AppFramework\Services\IInitialState;
use OCP\IDateTimeFormatter;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use Test\TestCase;
@ -101,172 +100,4 @@ class UserStatusWidgetTest extends TestCase {
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('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('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

@ -228,14 +228,17 @@ return array(
'OCP\\DB\\QueryBuilder\\IQueryFunction' => $baseDir . '/lib/public/DB/QueryBuilder/IQueryFunction.php',
'OCP\\DB\\Types' => $baseDir . '/lib/public/DB/Types.php',
'OCP\\Dashboard\\IAPIWidget' => $baseDir . '/lib/public/Dashboard/IAPIWidget.php',
'OCP\\Dashboard\\IAPIWidgetV2' => $baseDir . '/lib/public/Dashboard/IAPIWidgetV2.php',
'OCP\\Dashboard\\IButtonWidget' => $baseDir . '/lib/public/Dashboard/IButtonWidget.php',
'OCP\\Dashboard\\IConditionalWidget' => $baseDir . '/lib/public/Dashboard/IConditionalWidget.php',
'OCP\\Dashboard\\IIconWidget' => $baseDir . '/lib/public/Dashboard/IIconWidget.php',
'OCP\\Dashboard\\IManager' => $baseDir . '/lib/public/Dashboard/IManager.php',
'OCP\\Dashboard\\IOptionWidget' => $baseDir . '/lib/public/Dashboard/IOptionWidget.php',
'OCP\\Dashboard\\IReloadableWidget' => $baseDir . '/lib/public/Dashboard/IReloadableWidget.php',
'OCP\\Dashboard\\IWidget' => $baseDir . '/lib/public/Dashboard/IWidget.php',
'OCP\\Dashboard\\Model\\WidgetButton' => $baseDir . '/lib/public/Dashboard/Model/WidgetButton.php',
'OCP\\Dashboard\\Model\\WidgetItem' => $baseDir . '/lib/public/Dashboard/Model/WidgetItem.php',
'OCP\\Dashboard\\Model\\WidgetItems' => $baseDir . '/lib/public/Dashboard/Model/WidgetItems.php',
'OCP\\Dashboard\\Model\\WidgetOptions' => $baseDir . '/lib/public/Dashboard/Model/WidgetOptions.php',
'OCP\\Dashboard\\RegisterWidgetEvent' => $baseDir . '/lib/public/Dashboard/RegisterWidgetEvent.php',
'OCP\\DataCollector\\AbstractDataCollector' => $baseDir . '/lib/public/DataCollector/AbstractDataCollector.php',

View File

@ -261,14 +261,17 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\DB\\QueryBuilder\\IQueryFunction' => __DIR__ . '/../../..' . '/lib/public/DB/QueryBuilder/IQueryFunction.php',
'OCP\\DB\\Types' => __DIR__ . '/../../..' . '/lib/public/DB/Types.php',
'OCP\\Dashboard\\IAPIWidget' => __DIR__ . '/../../..' . '/lib/public/Dashboard/IAPIWidget.php',
'OCP\\Dashboard\\IAPIWidgetV2' => __DIR__ . '/../../..' . '/lib/public/Dashboard/IAPIWidgetV2.php',
'OCP\\Dashboard\\IButtonWidget' => __DIR__ . '/../../..' . '/lib/public/Dashboard/IButtonWidget.php',
'OCP\\Dashboard\\IConditionalWidget' => __DIR__ . '/../../..' . '/lib/public/Dashboard/IConditionalWidget.php',
'OCP\\Dashboard\\IIconWidget' => __DIR__ . '/../../..' . '/lib/public/Dashboard/IIconWidget.php',
'OCP\\Dashboard\\IManager' => __DIR__ . '/../../..' . '/lib/public/Dashboard/IManager.php',
'OCP\\Dashboard\\IOptionWidget' => __DIR__ . '/../../..' . '/lib/public/Dashboard/IOptionWidget.php',
'OCP\\Dashboard\\IReloadableWidget' => __DIR__ . '/../../..' . '/lib/public/Dashboard/IReloadableWidget.php',
'OCP\\Dashboard\\IWidget' => __DIR__ . '/../../..' . '/lib/public/Dashboard/IWidget.php',
'OCP\\Dashboard\\Model\\WidgetButton' => __DIR__ . '/../../..' . '/lib/public/Dashboard/Model/WidgetButton.php',
'OCP\\Dashboard\\Model\\WidgetItem' => __DIR__ . '/../../..' . '/lib/public/Dashboard/Model/WidgetItem.php',
'OCP\\Dashboard\\Model\\WidgetItems' => __DIR__ . '/../../..' . '/lib/public/Dashboard/Model/WidgetItems.php',
'OCP\\Dashboard\\Model\\WidgetOptions' => __DIR__ . '/../../..' . '/lib/public/Dashboard/Model/WidgetOptions.php',
'OCP\\Dashboard\\RegisterWidgetEvent' => __DIR__ . '/../../..' . '/lib/public/Dashboard/RegisterWidgetEvent.php',
'OCP\\DataCollector\\AbstractDataCollector' => __DIR__ . '/../../..' . '/lib/public/DataCollector/AbstractDataCollector.php',

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
*
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCP\Dashboard;
use OCP\Dashboard\Model\WidgetItems;
/**
* Interface IAPIWidgetV2
*
* @since 27.1.0
*/
interface IAPIWidgetV2 extends IWidget {
/**
* Items to render in the widget
*
* @since 27.1.0
*/
public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems;
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
*
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCP\Dashboard;
/**
* Allow {@see IAPIWidgetV2} to reload their items
*
* @since 27.1.0
*/
interface IReloadableWidget extends IAPIWidgetV2 {
/**
* Periodic interval in seconds in which to reload the widget's items
*
* @since 27.1.0
*/
public function getReloadInterval(): int;
}

View File

@ -6,6 +6,7 @@ declare(strict_types=1);
* @copyright 2021, Julien Veyssier <eneiluj@posteo.net>
*
* @author Julien Veyssier <eneiluj@posteo.net>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
@ -56,24 +57,30 @@ final class WidgetItem implements JsonSerializable {
*/
private $sinceId = '';
/**
* Overlay icon to show in the bottom right corner of {@see $iconUrl}
*
* @since 27.1.0
*/
private string $overlayIconUrl = '';
/**
* WidgetItem constructor
*
* @since 22.0.0
*
* @param string $type
*/
public function __construct(string $title = '',
string $subtitle = '',
string $link = '',
string $iconUrl = '',
string $sinceId = '') {
string $sinceId = '',
string $overlayIconUrl = '') {
$this->title = $title;
$this->subtitle = $subtitle;
$this->iconUrl = $iconUrl;
$this->link = $link;
$this->sinceId = $sinceId;
$this->overlayIconUrl = $overlayIconUrl;
}
/**
@ -132,6 +139,17 @@ final class WidgetItem implements JsonSerializable {
return $this->sinceId;
}
/**
* Get the overlay icon url
*
* @since 27.1.0
*
* @return string
*/
public function getOverlayIconUrl(): string {
return $this->overlayIconUrl;
}
/**
* @since 22.0.0
*
@ -143,6 +161,7 @@ final class WidgetItem implements JsonSerializable {
'title' => $this->getTitle(),
'link' => $this->getLink(),
'iconUrl' => $this->getIconUrl(),
'overlayIconUrl' => $this->getOverlayIconUrl(),
'sinceId' => $this->getSinceId(),
];
}

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
*
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCP\Dashboard\Model;
use JsonSerializable;
use OCP\Dashboard\IAPIWidgetV2;
/**
* Interface WidgetItems
*
* This class is used by {@see IAPIWidgetV2} interface.
* It represents an array of widget items and additional context information that can be provided to clients via the Dashboard API
*
* @see IAPIWidgetV2::getItemsV2
*
* @since 27.1.0
*/
class WidgetItems implements JsonSerializable {
/**
* @param $items WidgetItem[]
*
* @since 27.1.0
*/
public function __construct(
private array $items = [],
private string $emptyContentMessage = '',
private string $halfEmptyContentMessage = '',
) {
}
/**
* Items to render in the widgets
*
* @since 27.1.0
*
* @return WidgetItem[]
*/
public function getItems(): array {
return $this->items;
}
/**
* The "half" empty content message to show above the list of items.
*
* A non-empty string enables this feature.
* An empty string hides the message and disables this feature.
*
* @since 27.1.0
*/
public function getEmptyContentMessage(): string {
return $this->emptyContentMessage;
}
/**
* The empty content message to show in case of no items at all
*
* @since 27.1.0
*/
public function getHalfEmptyContentMessage(): string {
return $this->halfEmptyContentMessage;
}
/**
* @since 27.1.0
*/
public function jsonSerialize(): array {
$items = array_map(static function (WidgetItem $item) {
return $item->jsonSerialize();
}, $this->getItems());
return [
'items' => $items,
'emptyContentMessage' => $this->getEmptyContentMessage(),
'halfEmptyContentMessage' => $this->getHalfEmptyContentMessage(),
];
}
}

View File

@ -112,7 +112,6 @@ module.exports = {
updatenotification: path.join(__dirname, 'apps/updatenotification/src', 'init.js'),
},
user_status: {
dashboard: path.join(__dirname, 'apps/user_status/src', 'dashboard.js'),
menu: path.join(__dirname, 'apps/user_status/src', 'menu.js'),
},
weather_status: {