diff options
author | Richard Steinmetz <richard@steinmetz.cloud> | 2023-08-17 15:09:30 +0200 |
---|---|---|
committer | Joas Schilling <coding@schilljs.com> | 2023-08-22 08:36:53 +0200 |
commit | 6982597b6a6d319dacfbe3bee2edd2a39b3d6d68 (patch) | |
tree | 9a3cfc98c5ce08542e204ef37365491fa0a0404e /apps/dashboard | |
parent | 82835eaa4623180c41dad927b0ac1db1ed449362 (diff) | |
download | nextcloud-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.php | 2 | ||||
-rw-r--r-- | apps/dashboard/lib/Controller/DashboardApiController.php | 72 | ||||
-rw-r--r-- | apps/dashboard/lib/ResponseDefinitions.php | 10 | ||||
-rw-r--r-- | apps/dashboard/openapi.json | 134 | ||||
-rw-r--r-- | apps/dashboard/src/DashboardApp.vue | 106 | ||||
-rw-r--r-- | apps/dashboard/src/components/ApiDashboardWidget.vue | 140 |
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> |