aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dashboard
diff options
context:
space:
mode:
authorRichard Steinmetz <richard@steinmetz.cloud>2023-08-17 15:09:30 +0200
committerJoas Schilling <coding@schilljs.com>2023-08-22 08:36:53 +0200
commit6982597b6a6d319dacfbe3bee2edd2a39b3d6d68 (patch)
tree9a3cfc98c5ce08542e204ef37365491fa0a0404e /apps/dashboard
parent82835eaa4623180c41dad927b0ac1db1ed449362 (diff)
downloadnextcloud-server-6982597b6a6d319dacfbe3bee2edd2a39b3d6d68.tar.gz
nextcloud-server-6982597b6a6d319dacfbe3bee2edd2a39b3d6d68.zip
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>
Diffstat (limited to 'apps/dashboard')
-rw-r--r--apps/dashboard/appinfo/routes.php2
-rw-r--r--apps/dashboard/lib/Controller/DashboardApiController.php72
-rw-r--r--apps/dashboard/lib/ResponseDefinitions.php10
-rw-r--r--apps/dashboard/openapi.json134
-rw-r--r--apps/dashboard/src/DashboardApp.vue106
-rw-r--r--apps/dashboard/src/components/ApiDashboardWidget.vue140
6 files changed, 439 insertions, 25 deletions
diff --git a/apps/dashboard/appinfo/routes.php b/apps/dashboard/appinfo/routes.php
index c6891837384..e872c47084b 100644
--- a/apps/dashboard/appinfo/routes.php
+++ b/apps/dashboard/appinfo/routes.php
@@ -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'],
]
];
diff --git a/apps/dashboard/lib/Controller/DashboardApiController.php b/apps/dashboard/lib/Controller/DashboardApiController.php
index df1c75e4b68..8855bf71700 100644
--- a/apps/dashboard/lib/Controller/DashboardApiController.php
+++ b/apps/dashboard/lib/Controller/DashboardApiController.php
@@ -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 {
@@ -72,6 +77,24 @@ class DashboardApiController extends OCSController {
}
/**
+ * @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));
}
@@ -104,6 +120,31 @@ class DashboardApiController extends OCSController {
}
/**
+ * @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
*
* @NoAdminRequired
@@ -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);
diff --git a/apps/dashboard/lib/ResponseDefinitions.php b/apps/dashboard/lib/ResponseDefinitions.php
index 1c40f251f2a..b35531be2a7 100644
--- a/apps/dashboard/lib/ResponseDefinitions.php
+++ b/apps/dashboard/lib/ResponseDefinitions.php
@@ -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 {
}
diff --git a/apps/dashboard/openapi.json b/apps/dashboard/openapi.json
index 594aed76793..739ba2c4afc 100644
--- a/apps/dashboard/openapi.json
+++ b/apps/dashboard/openapi.json
@@ -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": []
diff --git a/apps/dashboard/src/DashboardApp.vue b/apps/dashboard/src/DashboardApp.vue
index 993340dae36..7a7b56da266 100644
--- a/apps/dashboard/src/DashboardApp.vue
+++ b/apps/dashboard/src/DashboardApp.vue
@@ -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;
}
}
}
diff --git a/apps/dashboard/src/components/ApiDashboardWidget.vue b/apps/dashboard/src/components/ApiDashboardWidget.vue
new file mode 100644
index 00000000000..daa97fc5428
--- /dev/null
+++ b/apps/dashboard/src/components/ApiDashboardWidget.vue
@@ -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>