diff options
-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 | ||||
-rw-r--r-- | apps/user_status/lib/Dashboard/UserStatusWidget.php | 27 | ||||
-rw-r--r-- | apps/user_status/src/dashboard.js | 44 | ||||
-rw-r--r-- | apps/user_status/src/views/Dashboard.vue | 121 | ||||
-rw-r--r-- | apps/user_status/tests/Unit/Dashboard/UserStatusWidgetTest.php | 171 | ||||
-rw-r--r-- | lib/composer/composer/autoload_classmap.php | 3 | ||||
-rw-r--r-- | lib/composer/composer/autoload_static.php | 3 | ||||
-rw-r--r-- | lib/public/Dashboard/IAPIWidgetV2.php | 43 | ||||
-rw-r--r-- | lib/public/Dashboard/IReloadableWidget.php | 41 | ||||
-rw-r--r-- | lib/public/Dashboard/Model/WidgetItem.php | 25 | ||||
-rw-r--r-- | lib/public/Dashboard/Model/WidgetItems.php | 100 | ||||
-rw-r--r-- | webpack.modules.js | 1 |
17 files changed, 667 insertions, 376 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> diff --git a/apps/user_status/lib/Dashboard/UserStatusWidget.php b/apps/user_status/lib/Dashboard/UserStatusWidget.php index 2e1de3cd6cf..89b9c05f805 100644 --- a/apps/user_status/lib/Dashboard/UserStatusWidget.php +++ b/apps/user_status/lib/Dashboard/UserStatusWidget.php @@ -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); } diff --git a/apps/user_status/src/dashboard.js b/apps/user_status/src/dashboard.js deleted file mode 100644 index 4554dcba1b0..00000000000 --- a/apps/user_status/src/dashboard.js +++ /dev/null @@ -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) - }) - -}) diff --git a/apps/user_status/src/views/Dashboard.vue b/apps/user_status/src/views/Dashboard.vue deleted file mode 100644 index 1f9201cd118..00000000000 --- a/apps/user_status/src/views/Dashboard.vue +++ /dev/null @@ -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> diff --git a/apps/user_status/tests/Unit/Dashboard/UserStatusWidgetTest.php b/apps/user_status/tests/Unit/Dashboard/UserStatusWidgetTest.php index 5481325510c..9e1c39f6858 100644 --- a/apps/user_status/tests/Unit/Dashboard/UserStatusWidgetTest.php +++ b/apps/user_status/tests/Unit/Dashboard/UserStatusWidgetTest.php @@ -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(); - } } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index a3ff130a65b..007b19692c5 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -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', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index bb6ad327c92..f9164a13aa3 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -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', diff --git a/lib/public/Dashboard/IAPIWidgetV2.php b/lib/public/Dashboard/IAPIWidgetV2.php new file mode 100644 index 00000000000..27cb6510c77 --- /dev/null +++ b/lib/public/Dashboard/IAPIWidgetV2.php @@ -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; +} diff --git a/lib/public/Dashboard/IReloadableWidget.php b/lib/public/Dashboard/IReloadableWidget.php new file mode 100644 index 00000000000..9f65653fbe6 --- /dev/null +++ b/lib/public/Dashboard/IReloadableWidget.php @@ -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; +} diff --git a/lib/public/Dashboard/Model/WidgetItem.php b/lib/public/Dashboard/Model/WidgetItem.php index 859a5652351..1c54d2bd426 100644 --- a/lib/public/Dashboard/Model/WidgetItem.php +++ b/lib/public/Dashboard/Model/WidgetItem.php @@ -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; } /** @@ -133,6 +140,17 @@ final class WidgetItem implements JsonSerializable { } /** + * Get the overlay icon url + * + * @since 27.1.0 + * + * @return string + */ + public function getOverlayIconUrl(): string { + return $this->overlayIconUrl; + } + + /** * @since 22.0.0 * * @return array @@ -143,6 +161,7 @@ final class WidgetItem implements JsonSerializable { 'title' => $this->getTitle(), 'link' => $this->getLink(), 'iconUrl' => $this->getIconUrl(), + 'overlayIconUrl' => $this->getOverlayIconUrl(), 'sinceId' => $this->getSinceId(), ]; } diff --git a/lib/public/Dashboard/Model/WidgetItems.php b/lib/public/Dashboard/Model/WidgetItems.php new file mode 100644 index 00000000000..4bb51f2f9b6 --- /dev/null +++ b/lib/public/Dashboard/Model/WidgetItems.php @@ -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(), + ]; + } +} diff --git a/webpack.modules.js b/webpack.modules.js index d70817884a3..1e48e51bded 100644 --- a/webpack.modules.js +++ b/webpack.modules.js @@ -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: { |