From 986cb88dc4f1940a18f802048bbbb9e51e48f139 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Thu, 22 Jun 2023 09:51:29 +0200 Subject: [PATCH 01/63] refactor: use generic Plugin component from the platform --- .../Visualization/IframePlugin.js | 115 ++++++++---------- 1 file changed, 49 insertions(+), 66 deletions(-) diff --git a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js index f32d90767..5a3ef982a 100644 --- a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js +++ b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js @@ -1,10 +1,9 @@ import { useCachedDataQuery } from '@dhis2/analytics' -import { useConfig } from '@dhis2/app-runtime' +import { useConfig, Plugin } from '@dhis2/app-runtime' import { CenteredContent, CircularLoader } from '@dhis2/ui' -import postRobot from '@krakenjs/post-robot' import PropTypes from 'prop-types' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useDispatch, useSelector, shallowEqual } from 'react-redux' import { acAddIframePluginStatus } from '../../../../actions/iframePluginStatus.js' import { CHART, @@ -15,9 +14,9 @@ import { getPluginOverrides } from '../../../../modules/localStorage.js' import { useCacheableSection } from '../../../../modules/useCacheableSection.js' import { INSTALLATION_STATUS_INSTALLING, - INSTALLATION_STATUS_READY, + // INSTALLATION_STATUS_READY, INSTALLATION_STATUS_UNKNOWN, - INSTALLATION_STATUS_WILL_NOT_INSTALL, + // INSTALLATION_STATUS_WILL_NOT_INSTALL, sGetIframePluginStatus, } from '../../../../reducers/iframePluginStatus.js' import { useUserSettings } from '../../../UserSettingsProvider.js' @@ -38,27 +37,39 @@ const IframePlugin = ({ isFirstOfType, }) => { const dispatch = useDispatch() - const iframePluginStatus = useSelector(sGetIframePluginStatus) + const iframePluginStatus = useSelector(sGetIframePluginStatus, shallowEqual) const { baseUrl } = useConfig() const { userSettings } = useUserSettings() - const iframeRef = useRef() const [error, setError] = useState(null) const { apps } = useCachedDataQuery() // When this mounts, check if the dashboard is recording const { isCached, recordingState } = useCacheableSection(dashboardId) - const [recordOnNextLoad, setRecordOnNextLoad] = useState( - recordingState === 'recording' - ) - - const prevPluginRef = useRef() - const onError = () => setError('plugin') + // TODO set this to false after first props transfer with true flag + const recordOnNextLoad = recordingState === 'recording' + // const [recordOnNextLoad, setRecordOnNextLoad] = useState( + // recordingState === 'recording' + // ) const pluginType = [CHART, REPORT_TABLE].includes(activeType) ? VISUALIZATION : activeType - const installationStatus = iframePluginStatus[pluginType] + + const onError = () => setError('plugin') + const onInstallationStatusChange = useCallback( + (installationStatus) => { + if (isFirstOfType) { + dispatch( + acAddIframePluginStatus({ + pluginType, + status: installationStatus, + }) + ) + } + }, + [dispatch, isFirstOfType, pluginType] + ) const pluginProps = useMemo( () => ({ @@ -67,6 +78,7 @@ const IframePlugin = ({ displayProperty: userSettings.keyAnalysisDisplayProperty, visualization, onError, + onInstallationStatusChange, // For caching: --- // Add user & dashboard IDs to cache ID to avoid removing a cached @@ -82,6 +94,7 @@ const IframePlugin = ({ dashboardId, itemId, isCached, + onInstallationStatusChange, recordOnNextLoad, ] ) @@ -107,23 +120,25 @@ const IframePlugin = ({ const iframeSrc = getIframeSrc() - useEffect(() => { - // Tell plugin to remove cached data if this dashboard has been removed - // from offline storage - if (iframeRef?.current && !isCached) { - postRobot - .send(iframeRef.current.contentWindow, 'removeCachedData') - .catch((err) => { - // catch error if iframe hasn't loaded yet - const msg = 'No handler found for post message:' - if (err.message.startsWith(msg)) { - return - } - console.error(err) - }) - } - }, [isCached]) - + // TODO figure out how to send this message via Plugin without re-rendering + // useEffect(() => { + // // Tell plugin to remove cached data if this dashboard has been removed + // // from offline storage + // if (iframeRef?.current && !isCached) { + // postRobot + // .send(iframeRef.current.contentWindow, 'removeCachedData') + // .catch((err) => { + // // catch error if iframe hasn't loaded yet + // const msg = 'No handler found for post message:' + // if (err.message.startsWith(msg)) { + // return + // } + // console.error(err) + // }) + // } + // }, [isCached]) + + /* useEffect(() => { if ( iframeRef?.current && @@ -165,30 +180,7 @@ const IframePlugin = ({ installationStatus, isFirstOfType, ]) - - useEffect(() => { - if (iframeRef?.current) { - const listener = postRobot.on( - 'installationStatus', - { - window: iframeRef.current.contentWindow, - }, - (event) => { - if (isFirstOfType) { - dispatch( - acAddIframePluginStatus({ - pluginType, - status: event.data.installationStatus, - }) - ) - } - } - ) - - return () => listener.cancel() - } - }, [pluginType, dispatch, visualization, iframePluginStatus, isFirstOfType]) - +*/ useEffect(() => { setError(null) }, [filterVersion, visualization.type]) @@ -235,16 +227,7 @@ const IframePlugin = ({ return (
{iframeSrc ? ( - + ) : null}
) From 85c477214c62d0cd75f2b477acc5cadbcbb54388 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Mon, 9 Oct 2023 10:20:10 +0200 Subject: [PATCH 02/63] refactor: handle recording on first load, pass width and height --- .../Visualization/IframePlugin.js | 86 ++++--------------- 1 file changed, 17 insertions(+), 69 deletions(-) diff --git a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js index 5a3ef982a..b8fe832a4 100644 --- a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js +++ b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js @@ -14,9 +14,7 @@ import { getPluginOverrides } from '../../../../modules/localStorage.js' import { useCacheableSection } from '../../../../modules/useCacheableSection.js' import { INSTALLATION_STATUS_INSTALLING, - // INSTALLATION_STATUS_READY, INSTALLATION_STATUS_UNKNOWN, - // INSTALLATION_STATUS_WILL_NOT_INSTALL, sGetIframePluginStatus, } from '../../../../reducers/iframePluginStatus.js' import { useUserSettings } from '../../../UserSettingsProvider.js' @@ -46,11 +44,10 @@ const IframePlugin = ({ // When this mounts, check if the dashboard is recording const { isCached, recordingState } = useCacheableSection(dashboardId) - // TODO set this to false after first props transfer with true flag - const recordOnNextLoad = recordingState === 'recording' - // const [recordOnNextLoad, setRecordOnNextLoad] = useState( - // recordingState === 'recording' - // ) + // set this to false after first props transfer with true flag + const [recordOnNextLoad, setRecordOnNextLoad] = useState( + recordingState === 'recording' + ) const pluginType = [CHART, REPORT_TABLE].includes(activeType) ? VISUALIZATION @@ -70,6 +67,11 @@ const IframePlugin = ({ }, [dispatch, isFirstOfType, pluginType] ) + const onPropsReceived = useCallback(() => { + if (recordOnNextLoad) { + setRecordOnNextLoad(false) + } + }, [recordOnNextLoad]) const pluginProps = useMemo( () => ({ @@ -79,6 +81,7 @@ const IframePlugin = ({ visualization, onError, onInstallationStatusChange, + onPropsReceived, // For caching: --- // Add user & dashboard IDs to cache ID to avoid removing a cached @@ -95,6 +98,7 @@ const IframePlugin = ({ itemId, isCached, onInstallationStatusChange, + onPropsReceived, recordOnNextLoad, ] ) @@ -120,67 +124,6 @@ const IframePlugin = ({ const iframeSrc = getIframeSrc() - // TODO figure out how to send this message via Plugin without re-rendering - // useEffect(() => { - // // Tell plugin to remove cached data if this dashboard has been removed - // // from offline storage - // if (iframeRef?.current && !isCached) { - // postRobot - // .send(iframeRef.current.contentWindow, 'removeCachedData') - // .catch((err) => { - // // catch error if iframe hasn't loaded yet - // const msg = 'No handler found for post message:' - // if (err.message.startsWith(msg)) { - // return - // } - // console.error(err) - // }) - // } - // }, [isCached]) - - /* - useEffect(() => { - if ( - iframeRef?.current && - (installationStatus === INSTALLATION_STATUS_READY || - installationStatus === INSTALLATION_STATUS_WILL_NOT_INSTALL || - isFirstOfType) - ) { - // if iframe has not sent initial request, set up a listener - if (iframeSrc !== prevPluginRef.current) { - prevPluginRef.current = iframeSrc - - const listener = postRobot.on( - 'getProps', - // listen for messages coming only from the iframe rendered by this component - { window: iframeRef.current.contentWindow }, - () => { - if (recordOnNextLoad) { - // Avoid recording unnecessarily, - // e.g. if plugin re-requests props for some reason - setRecordOnNextLoad(false) - } - return pluginProps - } - ) - - return () => listener.cancel() - } else { - postRobot.send( - iframeRef.current.contentWindow, - 'newProps', - pluginProps - ) - } - } - }, [ - recordOnNextLoad, - pluginProps, - iframeSrc, - installationStatus, - isFirstOfType, - ]) -*/ useEffect(() => { setError(null) }, [filterVersion, visualization.type]) @@ -227,7 +170,12 @@ const IframePlugin = ({ return (
{iframeSrc ? ( - + ) : null}
) From 50b6dbc8e76de1152e8ff053b2072668b30b4f49 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Fri, 24 May 2024 15:21:39 +0200 Subject: [PATCH 03/63] chore: update app-runtime and cli-app-scripts to latest --- .../Item/VisualizationItem/Visualization/IframePlugin.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js index b8fe832a4..5ec2d06ea 100644 --- a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js +++ b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js @@ -1,5 +1,6 @@ import { useCachedDataQuery } from '@dhis2/analytics' -import { useConfig, Plugin } from '@dhis2/app-runtime' +import { useConfig } from '@dhis2/app-runtime' +import { Plugin } from '@dhis2/app-runtime/experimental' import { CenteredContent, CircularLoader } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useCallback, useEffect, useMemo, useState } from 'react' From f391aac064eba0b2fc7800aabca7b1d0ac04f489 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Mon, 27 May 2024 15:35:00 +0200 Subject: [PATCH 04/63] fix: replace deprecated prop with new one This silences warnings in the console. --- src/components/DropdownButton/DropdownButton.js | 2 +- src/pages/edit/ItemSelector/ItemSelector.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/DropdownButton/DropdownButton.js b/src/components/DropdownButton/DropdownButton.js index 0e4125570..2f1e8c4a2 100644 --- a/src/components/DropdownButton/DropdownButton.js +++ b/src/components/DropdownButton/DropdownButton.js @@ -25,7 +25,7 @@ const DropdownButton = ({ {open && ( - + {component} diff --git a/src/pages/edit/ItemSelector/ItemSelector.js b/src/pages/edit/ItemSelector/ItemSelector.js index 5b104b887..bfe3ffbbc 100644 --- a/src/pages/edit/ItemSelector/ItemSelector.js +++ b/src/pages/edit/ItemSelector/ItemSelector.js @@ -107,7 +107,7 @@ const ItemSelector = () => { /> {isOpen && ( - +
Date: Mon, 27 May 2024 15:38:18 +0200 Subject: [PATCH 05/63] fix: silence linter warning This hopefully has a better solution. --- .../Item/VisualizationItem/Visualization/IframePlugin.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js index 5ec2d06ea..bd7dc4ca7 100644 --- a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js +++ b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js @@ -1,5 +1,6 @@ import { useCachedDataQuery } from '@dhis2/analytics' import { useConfig } from '@dhis2/app-runtime' +// eslint-disable-next-line import/no-unresolved import { Plugin } from '@dhis2/app-runtime/experimental' import { CenteredContent, CircularLoader } from '@dhis2/ui' import PropTypes from 'prop-types' From 7649773c75781e69dfece000674322e7fb080e32 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Mon, 27 May 2024 15:39:57 +0200 Subject: [PATCH 06/63] refactor: simplify communication for offline caching It should be enough to just use isParentCached for knowing when to start recording and removing the cache in the plugins. --- .../Visualization/IframePlugin.js | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js index bd7dc4ca7..2c453340a 100644 --- a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js +++ b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js @@ -44,12 +44,7 @@ const IframePlugin = ({ const { apps } = useCachedDataQuery() // When this mounts, check if the dashboard is recording - const { isCached, recordingState } = useCacheableSection(dashboardId) - - // set this to false after first props transfer with true flag - const [recordOnNextLoad, setRecordOnNextLoad] = useState( - recordingState === 'recording' - ) + const { isCached } = useCacheableSection(dashboardId) const pluginType = [CHART, REPORT_TABLE].includes(activeType) ? VISUALIZATION @@ -58,6 +53,7 @@ const IframePlugin = ({ const onError = () => setError('plugin') const onInstallationStatusChange = useCallback( (installationStatus) => { + console.log('DS installation status change', installationStatus) if (isFirstOfType) { dispatch( acAddIframePluginStatus({ @@ -69,11 +65,6 @@ const IframePlugin = ({ }, [dispatch, isFirstOfType, pluginType] ) - const onPropsReceived = useCallback(() => { - if (recordOnNextLoad) { - setRecordOnNextLoad(false) - } - }, [recordOnNextLoad]) const pluginProps = useMemo( () => ({ @@ -83,7 +74,6 @@ const IframePlugin = ({ visualization, onError, onInstallationStatusChange, - onPropsReceived, // For caching: --- // Add user & dashboard IDs to cache ID to avoid removing a cached @@ -91,7 +81,6 @@ const IframePlugin = ({ // TODO: May also want user ID too for multi-user situations cacheId: `${dashboardId}-${itemId}`, isParentCached: isCached, - recordOnNextLoad, }), [ userSettings, @@ -100,8 +89,6 @@ const IframePlugin = ({ itemId, isCached, onInstallationStatusChange, - onPropsReceived, - recordOnNextLoad, ] ) From dd256ea72e27800c1754d44e57abf14610d629e8 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Wed, 10 Jul 2024 11:49:59 +0200 Subject: [PATCH 07/63] fix: fix linting error --- .../Item/VisualizationItem/Visualization/IframePlugin.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js index 2c453340a..e2ff5e87b 100644 --- a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js +++ b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js @@ -2,6 +2,7 @@ import { useCachedDataQuery } from '@dhis2/analytics' import { useConfig } from '@dhis2/app-runtime' // eslint-disable-next-line import/no-unresolved import { Plugin } from '@dhis2/app-runtime/experimental' +import { useD2 } from '@dhis2/app-runtime-adapter-d2' import { CenteredContent, CircularLoader } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useCallback, useEffect, useMemo, useState } from 'react' From 50771b2277ed19b8769dab4c8c41c9b10e641daf Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Thu, 11 Jul 2024 09:58:47 +0200 Subject: [PATCH 08/63] fix: move visualization fetch in try/catch This is to avoid the error boundary to show up for uncaught errors (failed to fetch a visualization) which interferes with Cypress testing. --- src/components/Item/VisualizationItem/Item.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/Item/VisualizationItem/Item.js b/src/components/Item/VisualizationItem/Item.js index d80849952..21ddb0686 100644 --- a/src/components/Item/VisualizationItem/Item.js +++ b/src/components/Item/VisualizationItem/Item.js @@ -95,15 +95,15 @@ class Item extends Component { } async componentDidMount() { - // Avoid refetching the visualization already in the Redux store - // when the same dashboard item is added again. - // This also solves a flashing of all the "duplicated" dashboard items. - !this.props.visualization.id && - this.props.setVisualization( - await apiFetchVisualization(this.props.item) - ) - try { + // Avoid refetching the visualization already in the Redux store + // when the same dashboard item is added again. + // This also solves a flashing of all the "duplicated" dashboard items. + !this.props.visualization.id && + this.props.setVisualization( + await apiFetchVisualization(this.props.item) + ) + if ( this.props.settings .keyGatherAnalyticalObjectStatisticsInDashboardViews && From 27ff14e4a12b86b0e0f8978ec9a5d1ed249f9e2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Aug 2024 09:35:45 +0200 Subject: [PATCH 09/63] chore(deps): bump the security group with 3 updates (#3046) * chore(deps): bump the security group with 3 updates Updates `ejs` from 3.1.8 to 3.1.10 - [Release notes](https://github.com/mde/ejs/releases) - [Commits](https://github.com/mde/ejs/compare/v3.1.8...v3.1.10) Updates `tmpl` from 1.0.4 to 1.0.5 - [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5) Updates `word-wrap` from 1.2.3 to 1.2.5 - [Release notes](https://github.com/jonschlinkert/word-wrap/releases) - [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5) --- updated-dependencies: - dependency-name: ejs dependency-type: indirect dependency-group: security - dependency-name: tmpl dependency-type: indirect dependency-group: security - dependency-name: word-wrap dependency-type: indirect dependency-group: security ... Signed-off-by: dependabot[bot] * chore: break up command chain --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jen Jones Arnesen --- cypress/e2e/dashboard_filter/dashboard_filter.js | 1 + .../Item/VisualizationItem/Visualization/IframePlugin.js | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/e2e/dashboard_filter/dashboard_filter.js b/cypress/e2e/dashboard_filter/dashboard_filter.js index 2521e9593..d3f141b47 100644 --- a/cypress/e2e/dashboard_filter/dashboard_filter.js +++ b/cypress/e2e/dashboard_filter/dashboard_filter.js @@ -26,6 +26,7 @@ Then('the Period filter is applied to the dashboard', () => { cy.get(filterBadgeSel).contains(`Period: ${PERIOD}`).should('be.visible') // check the CHART +<<<<<<< HEAD // cy.get(`${gridItemSel}.VISUALIZATION`).getIframeBody().as('iframeBody') // cy.get('@iframeBody') // .find(`${chartSubtitleSel} > title`, EXTENDED_TIMEOUT) diff --git a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js index e2ff5e87b..2c453340a 100644 --- a/src/components/Item/VisualizationItem/Visualization/IframePlugin.js +++ b/src/components/Item/VisualizationItem/Visualization/IframePlugin.js @@ -2,7 +2,6 @@ import { useCachedDataQuery } from '@dhis2/analytics' import { useConfig } from '@dhis2/app-runtime' // eslint-disable-next-line import/no-unresolved import { Plugin } from '@dhis2/app-runtime/experimental' -import { useD2 } from '@dhis2/app-runtime-adapter-d2' import { CenteredContent, CircularLoader } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useCallback, useEffect, useMemo, useState } from 'react' From a911ab41f50bd0362b2fbdc1bb9a8277c1684230 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Fri, 9 Aug 2024 15:32:27 +0200 Subject: [PATCH 10/63] feat: support custom dashboard plugins (DHIS2-17283) --- src/components/Item/AppItem/Item.js | 38 ++++++++++++++++----- src/components/Item/AppItem/getIframeSrc.js | 34 +++++++++++++----- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/components/Item/AppItem/Item.js b/src/components/Item/AppItem/Item.js index 57ed1bf9b..cc4ab62d2 100644 --- a/src/components/Item/AppItem/Item.js +++ b/src/components/Item/AppItem/Item.js @@ -1,19 +1,27 @@ +// eslint-disable-next-line import/no-unresolved +import { Plugin } from '@dhis2/app-runtime/experimental' import i18n from '@dhis2/d2-i18n' import { Divider, colors, spacers, IconQuestion24 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' -import { connect } from 'react-redux' +import { connect, useSelector } from 'react-redux' import { EDIT, isEditMode } from '../../../modules/dashboardModes.js' +import { useCacheableSection } from '../../../modules/useCacheableSection.js' import { sGetItemFiltersRoot, DEFAULT_STATE_ITEM_FILTERS, } from '../../../reducers/itemFilters.js' +import { sGetSelectedId } from '../../../reducers/selected.js' import ItemHeader from '../ItemHeader/ItemHeader.js' import { getIframeSrc } from './getIframeSrc.js' import styles from './styles/AppItem.module.css' const AppItem = ({ dashboardMode, item, itemFilters, apps, isFullscreen }) => { + const dashboardId = useSelector(sGetSelectedId) + + const { isCached } = useCacheableSection(dashboardId) + let appDetails const appKey = item.appKey @@ -26,7 +34,9 @@ const AppItem = ({ dashboardMode, item, itemFilters, apps, isFullscreen }) => { appDetails?.settings?.dashboardWidget?.hideTitle && dashboardMode !== EDIT - return appDetails && appDetails.name && appDetails.launchUrl ? ( + const iframeSrc = getIframeSrc(appDetails, item, itemFilters) + + return iframeSrc ? ( <> {!hideTitle && ( <> @@ -39,15 +49,27 @@ const AppItem = ({ dashboardMode, item, itemFilters, apps, isFullscreen }) => { )} -