From c6cbf61bb710e5741e6a941dc36c9acb91624714 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Tue, 18 Feb 2025 14:06:15 +0100 Subject: [PATCH 01/43] feat: show preferred or default dashboard without accessing dashboard list --- i18n/en.pot | 7 ++----- src/actions/selected.js | 10 ---------- src/components/App.js | 19 +++---------------- .../InformationBlock/InformationBlock.js | 10 +++++----- src/reducers/dashboards.js | 9 +-------- src/reducers/selected.js | 3 +++ 6 files changed, 14 insertions(+), 44 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index e707c3631..42d4e35d1 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-02-07T10:20:57.831Z\n" -"PO-Revision-Date: 2025-02-07T10:20:57.833Z\n" +"POT-Creation-Date: 2025-02-18T13:06:19.701Z\n" +"PO-Revision-Date: 2025-02-18T13:06:19.703Z\n" msgid "Untitled dashboard" msgstr "Untitled dashboard" @@ -560,9 +560,6 @@ msgstr "Create a new dashboard with the + button." msgid "Your most viewed dashboards" msgstr "Your most viewed dashboards" -msgid "No dashboards found. Use the + button to create a new dashboard." -msgstr "No dashboards found. Use the + button to create a new dashboard." - msgid "Requested dashboard not found" msgstr "Requested dashboard not found" diff --git a/src/actions/selected.js b/src/actions/selected.js index 397e692ff..d593eaf52 100644 --- a/src/actions/selected.js +++ b/src/actions/selected.js @@ -8,7 +8,6 @@ import { CLEAR_SELECTED, sGetSelectedId, } from '../reducers/selected.js' -import { acAppendDashboards } from './dashboards.js' import { acClearItemActiveTypes } from './itemActiveTypes.js' import { acClearItemFilters } from './itemFilters.js' import { acClearVisualizations } from './visualizations.js' @@ -30,15 +29,6 @@ export const tSetSelectedDashboardById = const dashboard = await apiFetchDashboard(dataEngine, id, { mode: VIEW, }) - dispatch( - acAppendDashboards([ - { - id: dashboard.id, - displayName: dashboard.displayName, - starred: dashboard.starred, - }, - ]) - ) if (username) { storePreferredDashboardId(username, id) diff --git a/src/components/App.js b/src/components/App.js index 66e12a726..942a01313 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -5,7 +5,6 @@ import React, { useEffect } from 'react' import { connect } from 'react-redux' import { Redirect, HashRouter as Router, Route, Switch } from 'react-router-dom' import { acClearActiveModalDimension } from '../actions/activeModalDimension.js' -import { tFetchDashboards } from '../actions/dashboards.js' import { acClearDashboardsFilter } from '../actions/dashboardsFilter.js' import { acClearEditDashboard } from '../actions/editDashboard.js' import { acClearItemActiveTypes } from '../actions/itemActiveTypes.js' @@ -27,21 +26,11 @@ import './styles/ItemGrid.css' const App = (props) => { const { systemSettings } = useSystemSettings() const { currentUser } = useCachedDataQuery() + const { setShowDescription } = props useEffect(() => { - props.fetchDashboards() - props.setShowDescription() - - // store the headerbar height for controlbar height calculations - const headerbarHeight = document - .querySelector('header') - .getBoundingClientRect().height - - document.documentElement.style.setProperty( - '--headerbar-height', - `${headerbarHeight}px` - ) - }, []) + setShowDescription() + }, [setShowDescription]) return ( systemSettings && ( @@ -113,13 +102,11 @@ const App = (props) => { } App.propTypes = { - fetchDashboards: PropTypes.func, resetState: PropTypes.func, setShowDescription: PropTypes.func, } const mapDispatchToProps = { - fetchDashboards: tFetchDashboards, setShowDescription: tSetShowDescription, resetState: () => (dispatch) => { dispatch(acSetSelected({})) diff --git a/src/components/DashboardsBar/InformationBlock/InformationBlock.js b/src/components/DashboardsBar/InformationBlock/InformationBlock.js index 9fa1ac01d..af90f1d9b 100644 --- a/src/components/DashboardsBar/InformationBlock/InformationBlock.js +++ b/src/components/DashboardsBar/InformationBlock/InformationBlock.js @@ -4,8 +4,10 @@ import PropTypes from 'prop-types' import React, { useCallback } from 'react' import { connect } from 'react-redux' import { acSetDashboardStarred } from '../../../actions/dashboards.js' -import { sGetDashboardStarred } from '../../../reducers/dashboards.js' -import { sGetSelected } from '../../../reducers/selected.js' +import { + sGetSelected, + sGetSelectedStarred, +} from '../../../reducers/selected.js' import ActionsBar from './ActionsBar.js' import { apiStarDashboard } from './apiStarDashboard.js' import LastUpdatedTag from './LastUpdatedTag.js' @@ -77,9 +79,7 @@ const mapStateToProps = (state) => { return { displayName: dashboard.displayName, id: dashboard.id, - starred: dashboard.id - ? sGetDashboardStarred(state, dashboard.id) - : false, + starred: dashboard.id ? sGetSelectedStarred(state) : false, } } diff --git a/src/reducers/dashboards.js b/src/reducers/dashboards.js index 91bc17089..cb2d8cfff 100644 --- a/src/reducers/dashboards.js +++ b/src/reducers/dashboards.js @@ -71,14 +71,7 @@ export const sDashboardsIsFetching = (state) => { return sGetDashboardsRoot(state) === null } -/** - * Selector which returns all dashboards - * - * @function - * @param {Object} state The current state - * @returns {Object | undefined} - */ -export const sGetAllDashboards = (state) => orObject(sGetDashboardsRoot(state)) +const sGetAllDashboards = (state) => orObject(sGetDashboardsRoot(state)) // selector level 2 diff --git a/src/reducers/selected.js b/src/reducers/selected.js index df772d96f..37c7ea00e 100644 --- a/src/reducers/selected.js +++ b/src/reducers/selected.js @@ -12,6 +12,7 @@ const SELECTED_PROPERTIES = { dashboardItems: [], layout: [], itemConfig: {}, + starred: false, } export default (state = DEFAULT_SELECTED_STATE, action) => { @@ -37,6 +38,8 @@ export const sGetSelected = (state) => state.selected export const sGetSelectedId = (state) => sGetSelected(state).id +export const sGetSelectedStarred = (state) => sGetSelected(state).starred + export const sGetSelectedDisplayName = (state) => sGetSelected(state).displayName From d1bd466c8f4fbd306344279ec133f949ad3a4320 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Tue, 18 Feb 2025 14:19:49 +0100 Subject: [PATCH 02/43] feat: star and unstar a dashboard moved to selected --- src/actions/dashboards.js | 12 +----------- src/actions/selected.js | 6 ++++++ .../InformationBlock/InformationBlock.js | 6 +++--- src/reducers/dashboards.js | 10 ---------- src/reducers/selected.js | 7 +++++++ 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/actions/dashboards.js b/src/actions/dashboards.js index 69af6b4f9..8065398e3 100644 --- a/src/actions/dashboards.js +++ b/src/actions/dashboards.js @@ -1,10 +1,6 @@ import { apiFetchDashboards } from '../api/fetchAllDashboards.js' import { arrayToIdMap } from '../modules/util.js' -import { - SET_DASHBOARDS, - ADD_DASHBOARDS, - SET_DASHBOARD_STARRED, -} from '../reducers/dashboards.js' +import { SET_DASHBOARDS, ADD_DASHBOARDS } from '../reducers/dashboards.js' // actions @@ -18,12 +14,6 @@ export const acAppendDashboards = (dashboards) => ({ value: arrayToIdMap(dashboards), }) -export const acSetDashboardStarred = (id, isStarred) => ({ - type: SET_DASHBOARD_STARRED, - id, - value: isStarred, -}) - // thunks export const tFetchDashboards = diff --git a/src/actions/selected.js b/src/actions/selected.js index d593eaf52..068f0ccdb 100644 --- a/src/actions/selected.js +++ b/src/actions/selected.js @@ -6,6 +6,7 @@ import { storePreferredDashboardId } from '../modules/localStorage.js' import { SET_SELECTED, CLEAR_SELECTED, + SET_SELECTED_STARRED, sGetSelectedId, } from '../reducers/selected.js' import { acClearItemActiveTypes } from './itemActiveTypes.js' @@ -23,6 +24,11 @@ export const acClearSelected = () => ({ type: CLEAR_SELECTED, }) +export const acSetSelectedStarred = (isStarred) => ({ + type: SET_SELECTED_STARRED, + value: isStarred, +}) + // thunks export const tSetSelectedDashboardById = (id, username) => async (dispatch, getState, dataEngine) => { diff --git a/src/components/DashboardsBar/InformationBlock/InformationBlock.js b/src/components/DashboardsBar/InformationBlock/InformationBlock.js index af90f1d9b..0490521ed 100644 --- a/src/components/DashboardsBar/InformationBlock/InformationBlock.js +++ b/src/components/DashboardsBar/InformationBlock/InformationBlock.js @@ -3,7 +3,7 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useCallback } from 'react' import { connect } from 'react-redux' -import { acSetDashboardStarred } from '../../../actions/dashboards.js' +import { acSetSelectedStarred } from '../../../actions/selected.js' import { sGetSelected, sGetSelectedStarred, @@ -30,7 +30,7 @@ const InformationBlock = ({ () => apiStarDashboard(dataEngine, id, !starred) .then(() => { - setDashboardStarred(id, !starred) + setDashboardStarred(!starred) }) .catch(() => { const msg = starred @@ -84,5 +84,5 @@ const mapStateToProps = (state) => { } export default connect(mapStateToProps, { - setDashboardStarred: acSetDashboardStarred, + setDashboardStarred: acSetSelectedStarred, })(InformationBlock) diff --git a/src/reducers/dashboards.js b/src/reducers/dashboards.js index cb2d8cfff..40788f380 100644 --- a/src/reducers/dashboards.js +++ b/src/reducers/dashboards.js @@ -5,7 +5,6 @@ import { orObject } from '../modules/util.js' export const SET_DASHBOARDS = 'SET_DASHBOARDS' export const ADD_DASHBOARDS = 'ADD_DASHBOARDS' -export const SET_DASHBOARD_STARRED = 'SET_DASHBOARD_STARRED' export const EMPTY_DASHBOARDS = {} export const DEFAULT_STATE_DASHBOARDS = null @@ -28,15 +27,6 @@ export default (state = DEFAULT_STATE_DASHBOARDS, action) => { ...action.value, } } - case SET_DASHBOARD_STARRED: { - return { - ...state, - [action.id]: { - ...state[action.id], - starred: action.value, - }, - } - } default: return state } diff --git a/src/reducers/selected.js b/src/reducers/selected.js index 37c7ea00e..444a74356 100644 --- a/src/reducers/selected.js +++ b/src/reducers/selected.js @@ -1,5 +1,6 @@ export const SET_SELECTED = 'SET_SELECTED' export const CLEAR_SELECTED = 'CLEAR_SELECTED' +export const SET_SELECTED_STARRED = 'SET_SELECTED_STARRED' export const DEFAULT_SELECTED_STATE = {} const SELECTED_PROPERTIES = { @@ -27,6 +28,12 @@ export default (state = DEFAULT_SELECTED_STATE, action) => { case CLEAR_SELECTED: { return DEFAULT_SELECTED_STATE } + case SET_SELECTED_STARRED: { + return { + ...state, + starred: action.value, + } + } default: return state } From 2cdaf23616ee220add1b1365cb75d15569c0f2dc Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Tue, 18 Feb 2025 14:51:45 +0100 Subject: [PATCH 03/43] chore: fetch 1 dashboard to make sure that at least one exists --- i18n/en.pot | 7 +++- src/pages/view/CacheableViewDashboard.js | 51 ++++++++++-------------- src/reducers/dashboards.js | 7 ---- 3 files changed, 25 insertions(+), 40 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 42d4e35d1..09c3101fd 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-02-18T13:06:19.701Z\n" -"PO-Revision-Date: 2025-02-18T13:06:19.703Z\n" +"POT-Creation-Date: 2025-02-18T13:51:48.893Z\n" +"PO-Revision-Date: 2025-02-18T13:51:48.895Z\n" msgid "Untitled dashboard" msgstr "Untitled dashboard" @@ -560,6 +560,9 @@ msgstr "Create a new dashboard with the + button." msgid "Your most viewed dashboards" msgstr "Your most viewed dashboards" +msgid "No dashboards found. Use the + button to create a new dashboard." +msgstr "No dashboards found. Use the + button to create a new dashboard." + msgid "Requested dashboard not found" msgstr "Requested dashboard not found" diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 8f951c68d..0e81d9de1 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -1,7 +1,6 @@ import { useCachedDataQuery } from '@dhis2/analytics' -import { CacheableSection } from '@dhis2/app-runtime' +import { CacheableSection, useDataQuery } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' -import isEmpty from 'lodash/isEmpty.js' import PropTypes from 'prop-types' import React, { useEffect } from 'react' import { connect } from 'react-redux' @@ -11,22 +10,23 @@ import LoadingMask from '../../components/LoadingMask.js' import NoContentMessage from '../../components/NoContentMessage.js' import getCacheableSectionId from '../../modules/getCacheableSectionId.js' import { getPreferredDashboardId } from '../../modules/localStorage.js' -import { - sDashboardsIsFetching, - sGetDashboardById, - sGetDashboardsSortedByStarred, -} from '../../reducers/dashboards.js' import { sGetSelectedId } from '../../reducers/selected.js' import ViewDashboard from './ViewDashboard.js' -const CacheableViewDashboard = ({ - clearSelectedDashboard, - dashboardsIsEmpty, - dashboardsLoaded, - id, - selectedId, -}) => { +const query = { + dashboards: { + resource: 'dashboards', + params: { + fields: 'id', + paging: true, + pageSize: 1, + }, + }, +} + +const CacheableViewDashboard = ({ clearSelectedDashboard, id, selectedId }) => { const { currentUser } = useCachedDataQuery() + const { data, loading, fetching } = useDataQuery(query) useEffect(() => { if (id === null && selectedId !== null) { @@ -34,17 +34,17 @@ const CacheableViewDashboard = ({ } }, [id, selectedId, clearSelectedDashboard]) - if (!dashboardsLoaded) { + if (loading || fetching) { return } - if (dashboardsIsEmpty || id === null) { + if (!data?.dashboards.dashboards.length || id === null) { return ( <> { - const dashboards = sGetDashboardsSortedByStarred(state) // match is provided by the react-router-dom const routeId = ownProps.match?.params?.dashboardId || null - let dashboardToSelect = null - if (routeId) { - dashboardToSelect = sGetDashboardById(state, routeId) || null - } else { - const lastStoredDashboardId = getPreferredDashboardId(ownProps.username) - const dash = sGetDashboardById(state, lastStoredDashboardId) - dashboardToSelect = lastStoredDashboardId && dash ? dash : dashboards[0] - } + const dashboardIdToSelect = + routeId || getPreferredDashboardId(ownProps.username) return { - dashboardsIsEmpty: isEmpty(dashboards), - dashboardsLoaded: !sDashboardsIsFetching(state), - id: dashboardToSelect?.id || null, + id: dashboardIdToSelect || null, selectedId: sGetSelectedId(state) || null, } } diff --git a/src/reducers/dashboards.js b/src/reducers/dashboards.js index 40788f380..1288f6f42 100644 --- a/src/reducers/dashboards.js +++ b/src/reducers/dashboards.js @@ -54,13 +54,6 @@ export const sGetDashboardsRoot = (state) => state.dashboards export const sGetDashboardById = (state, id) => (sGetDashboardsRoot(state) || EMPTY_DASHBOARDS)[id] -export const sGetDashboardStarred = (state, id) => - sGetDashboardById(state, id).starred - -export const sDashboardsIsFetching = (state) => { - return sGetDashboardsRoot(state) === null -} - const sGetAllDashboards = (state) => orObject(sGetDashboardsRoot(state)) // selector level 2 From 8e2552d5992169ebc19e866a472962e924abd721 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Tue, 18 Feb 2025 14:55:24 +0100 Subject: [PATCH 04/43] feat: no longer have access to the dashboard name since not fetching list --- i18n/en.pot | 7 ++-- src/pages/view/ViewDashboard.js | 29 ++++------------ src/reducers/__tests__/dashboards.spec.js | 41 ----------------------- src/reducers/dashboards.js | 16 --------- 4 files changed, 8 insertions(+), 85 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 09c3101fd..26bab56a5 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-02-18T13:51:48.893Z\n" -"PO-Revision-Date: 2025-02-18T13:51:48.895Z\n" +"POT-Creation-Date: 2025-02-18T13:55:28.345Z\n" +"PO-Revision-Date: 2025-02-18T13:55:28.347Z\n" msgid "Untitled dashboard" msgstr "Untitled dashboard" @@ -610,9 +610,6 @@ msgid_plural "{{count}} filters active" msgstr[0] "{{count}} filter active" msgstr[1] "{{count}} filters active" -msgid "Loading dashboard – {{name}}" -msgstr "Loading dashboard – {{name}}" - msgid "Loading dashboard" msgstr "Loading dashboard" diff --git a/src/pages/view/ViewDashboard.js b/src/pages/view/ViewDashboard.js index a053c3aab..4bc93af40 100644 --- a/src/pages/view/ViewDashboard.js +++ b/src/pages/view/ViewDashboard.js @@ -20,7 +20,6 @@ import DashboardContainer from '../../components/DashboardContainer.js' import DashboardsBar from '../../components/DashboardsBar/index.js' import { setHeaderbarVisible } from '../../modules/setHeaderbarVisible.js' import { useCacheableSection } from '../../modules/useCacheableSection.js' -import { sGetDashboardById } from '../../reducers/dashboards.js' import { sGetPassiveViewRegistered } from '../../reducers/passiveViewRegistered.js' import { sGetSelectedId } from '../../reducers/selected.js' import classes from './styles/ViewDashboard.module.css' @@ -32,7 +31,6 @@ const ViewDashboard = ({ fetchDashboard, passiveViewRegistered, registerPassiveView, - requestedDashboardName, requestedId, setSelectedAsOffline, username, @@ -50,14 +48,10 @@ const ViewDashboard = ({ const loadDashboard = useCallback(async () => { setLoading(true) - alertTimeoutRef.current = setTimeout(() => { - const message = requestedDashboardName - ? i18n.t('Loading dashboard – {{name}}', { - name: requestedDashboardName, - }) - : i18n.t('Loading dashboard') - showAlert({ message }) - }, 500) + alertTimeoutRef.current = setTimeout( + () => showAlert({ message: i18n.t('Loading dashboard') }), + 500 + ) try { await fetchDashboard(requestedId, username) @@ -69,14 +63,7 @@ const ViewDashboard = ({ setLoading(false) clearTimeout(alertTimeoutRef.current) } - }, [ - fetchDashboard, - requestedDashboardName, - requestedId, - setSelectedAsOffline, - showAlert, - username, - ]) + }, [fetchDashboard, requestedId, setSelectedAsOffline, showAlert, username]) useEffect(() => { if (!loading && !loaded && !loadFailed) { @@ -153,18 +140,14 @@ ViewDashboard.propTypes = { fetchDashboard: PropTypes.func, passiveViewRegistered: PropTypes.bool, registerPassiveView: PropTypes.func, - requestedDashboardName: PropTypes.string, requestedId: PropTypes.string, setSelectedAsOffline: PropTypes.func, username: PropTypes.string, } -const mapStateToProps = (state, ownProps) => { - const dashboard = sGetDashboardById(state, ownProps.requestedId) || {} - +const mapStateToProps = (state) => { return { passiveViewRegistered: sGetPassiveViewRegistered(state), - requestedDashboardName: dashboard.displayName || null, currentId: sGetSelectedId(state), } } diff --git a/src/reducers/__tests__/dashboards.spec.js b/src/reducers/__tests__/dashboards.spec.js index 10b67d3ef..e80cfad79 100644 --- a/src/reducers/__tests__/dashboards.spec.js +++ b/src/reducers/__tests__/dashboards.spec.js @@ -1,12 +1,9 @@ import reducer, { DEFAULT_STATE_DASHBOARDS, sGetDashboardsRoot, - sGetDashboardById, - sGetAllDashboards, sGetDashboardsSortedByStarred, SET_DASHBOARDS, ADD_DASHBOARDS, - SET_DASHBOARD_STARRED, } from '../dashboards.js' const dashId1 = 'dash1' @@ -76,26 +73,6 @@ describe('dashboards reducer', () => { expect(actualState).toEqual(expectedState) }) - - it('SET_DASHBOARD_STARRED: should set "starred" on a dashboard', () => { - const starredValue = true - - const actualState = reducer(dashboardsState, { - type: SET_DASHBOARD_STARRED, - id: dashId1, - value: starredValue, - }) - - const expectedState = { - ...dashboardsState, - [dashId1]: { - ...dashboardsState[dashId1], - starred: starredValue, - }, - } - - expect(actualState).toEqual(expectedState) - }) }) const testState = { @@ -114,24 +91,6 @@ describe('dashboards selectors', () => { expect(actualState).toEqual(dashboardsState) }) - it('sGetDashboardById: should return dashboard with the provided id', () => { - const actualState = sGetDashboardById(testState, dashId1) - - expect(actualState).toEqual(dashboardsState[dashId1]) - }) - - it('sGetDashboardById: should return undefined', () => { - const actualState = sGetDashboardById(testState, 'NO_MATCH') - - expect(actualState).toEqual(undefined) - }) - - it('sGetAllDashboards: should return an object with all dashboards', () => { - const actualState = sGetAllDashboards(testState) - - expect(actualState).toEqual(dashboardsState) - }) - it('sGetDashboardsSortedByStarred: should return an array of dashboards sorted by starred/displayName-asc, then unstarred/displayName-asc', () => { const actualState = sGetDashboardsSortedByStarred(testState) diff --git a/src/reducers/dashboards.js b/src/reducers/dashboards.js index 1288f6f42..2ddb78cdb 100644 --- a/src/reducers/dashboards.js +++ b/src/reducers/dashboards.js @@ -38,22 +38,6 @@ export const sGetDashboardsRoot = (state) => state.dashboards // selector level 1 -/** - * Selector which returns a dashboard by id from the state object - * If no matching dashboard is found it returns undefined - * If dashboards is null, then the dashboards api request - * has not yet completed. If dashboards is an empty object - * then the dashboards api request is complete, but no dashboards - * were returned - * - * @function - * @param {Object} state The current state - * @param {Number} id The id of the dashboard - * @returns {Object | undefined} - */ -export const sGetDashboardById = (state, id) => - (sGetDashboardsRoot(state) || EMPTY_DASHBOARDS)[id] - const sGetAllDashboards = (state) => orObject(sGetDashboardsRoot(state)) // selector level 2 From ca758e08bdd6ee72a787d42b2074e9264c3bda35 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 21 Feb 2025 12:12:14 +0100 Subject: [PATCH 05/43] feat: component NavigationMenu now works with filter --- i18n/en.pot | 4 +- src/actions/dashboards.js | 23 --- src/actions/editDashboard.js | 4 - src/api/fetchAllDashboards.js | 19 --- .../NavigationMenu/EndIntersectionDetector.js | 24 +++ .../NavigationMenu/NavigationMenuLatest.js | 150 ++++++++++++++++++ .../DashboardsBar/NavigationMenu/index.js | 3 +- .../styles/EndIntersectionDetector.module.css | 9 ++ .../styles/NavigationMenu.module.css | 2 +- src/pages/edit/ActionsBar.js | 4 - src/reducers/dashboards.js | 60 ------- src/reducers/index.js | 2 - 12 files changed, 188 insertions(+), 116 deletions(-) delete mode 100644 src/actions/dashboards.js delete mode 100644 src/api/fetchAllDashboards.js create mode 100644 src/components/DashboardsBar/NavigationMenu/EndIntersectionDetector.js create mode 100644 src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js create mode 100644 src/components/DashboardsBar/NavigationMenu/styles/EndIntersectionDetector.module.css delete mode 100644 src/reducers/dashboards.js diff --git a/i18n/en.pot b/i18n/en.pot index 26bab56a5..f47e356e2 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-02-18T13:55:28.345Z\n" -"PO-Revision-Date: 2025-02-18T13:55:28.347Z\n" +"POT-Creation-Date: 2025-02-21T08:42:49.386Z\n" +"PO-Revision-Date: 2025-02-21T08:42:49.387Z\n" msgid "Untitled dashboard" msgstr "Untitled dashboard" diff --git a/src/actions/dashboards.js b/src/actions/dashboards.js deleted file mode 100644 index 8065398e3..000000000 --- a/src/actions/dashboards.js +++ /dev/null @@ -1,23 +0,0 @@ -import { apiFetchDashboards } from '../api/fetchAllDashboards.js' -import { arrayToIdMap } from '../modules/util.js' -import { SET_DASHBOARDS, ADD_DASHBOARDS } from '../reducers/dashboards.js' - -// actions - -export const acSetDashboards = (dashboards) => ({ - type: SET_DASHBOARDS, - value: arrayToIdMap(dashboards), -}) - -export const acAppendDashboards = (dashboards) => ({ - type: ADD_DASHBOARDS, - value: arrayToIdMap(dashboards), -}) - -// thunks - -export const tFetchDashboards = - () => async (dispatch, getState, dataEngine) => { - const dashboards = await apiFetchDashboards(dataEngine) - return dispatch(acSetDashboards(dashboards)) - } diff --git a/src/actions/editDashboard.js b/src/actions/editDashboard.js index 9e4a0ccac..13e59c338 100644 --- a/src/actions/editDashboard.js +++ b/src/actions/editDashboard.js @@ -30,7 +30,6 @@ import { sGetItemConfigInsertPosition, RECEIVED_CODE, } from '../reducers/editDashboard.js' -import { tFetchDashboards } from './dashboards.js' // actions @@ -183,8 +182,5 @@ export const tSaveDashboard = () => async (dispatch, getState, dataEngine) => { ? await updateDashboard(dataEngine, dashboardToSave) : await postDashboard(dataEngine, dashboardToSave) - // update the dashboard list - await dispatch(tFetchDashboards()) - return Promise.resolve(dashboardId) } diff --git a/src/api/fetchAllDashboards.js b/src/api/fetchAllDashboards.js deleted file mode 100644 index 45ad2dc88..000000000 --- a/src/api/fetchAllDashboards.js +++ /dev/null @@ -1,19 +0,0 @@ -export const dashboardsQuery = { - resource: 'dashboards', - params: { - fields: ['id', 'displayName', 'favorite~rename(starred)'], - paging: false, - }, -} - -export const apiFetchDashboards = async (dataEngine) => { - try { - const dashboardsData = await dataEngine.query({ - dashboards: dashboardsQuery, - }) - - return dashboardsData.dashboards.dashboards - } catch (error) { - console.log('Error: ', error) - } -} diff --git a/src/components/DashboardsBar/NavigationMenu/EndIntersectionDetector.js b/src/components/DashboardsBar/NavigationMenu/EndIntersectionDetector.js new file mode 100644 index 000000000..a1776699b --- /dev/null +++ b/src/components/DashboardsBar/NavigationMenu/EndIntersectionDetector.js @@ -0,0 +1,24 @@ +import { IntersectionDetector } from '@dhis2-ui/intersection-detector' +import PropTypes from 'prop-types' +import React from 'react' +import styles from './styles/EndIntersectionDetector.module.css' + +export const EndIntersectionDetector = ({ rootRef, onEndReached }) => { + return ( +
+ + isIntersecting && onEndReached() + } + /> +
+ ) +} + +EndIntersectionDetector.propTypes = { + rootRef: PropTypes.shape({ + current: PropTypes.instanceOf(HTMLElement), + }).isRequired, + onEndReached: PropTypes.func.isRequired, +} diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js new file mode 100644 index 000000000..00f008fec --- /dev/null +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js @@ -0,0 +1,150 @@ +import { useDataEngine } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { Menu, Input } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useEffect, useCallback, useRef, useState } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { acSetDashboardsFilter } from '../../../actions/dashboardsFilter.js' +import { sGetDashboardsFilter } from '../../../reducers/dashboardsFilter.js' +import { EndIntersectionDetector } from './EndIntersectionDetector.js' +import { NavigationMenuItem } from './NavigationMenuItem.js' +import styles from './styles/NavigationMenu.module.css' +import itemStyles from './styles/NavigationMenuItem.module.css' + +const dashboardsQuery = { + resource: 'dashboards', + params: ({ page, searchTerm }) => { + return { + fields: 'id,displayName,favorite~rename(starred)', + order: 'favorite:desc,displayName:asc', + filter: searchTerm ? `displayName:ilike:${searchTerm}` : undefined, + paging: true, + pageSize: 8, + page, + } + }, +} + +export const NavigationMenu = ({ close }) => { + const dataEngine = useDataEngine() + const dispatch = useDispatch() + const filterText = useSelector(sGetDashboardsFilter) + + const [state, setState] = useState({ + dashboards: [], + nextPage: 1, + searchTerm: filterText, + }) + + const fetchDashboards = useCallback( + async ({ page, searchTerm }) => { + const data = await dataEngine.query( + { dashboards: dashboardsQuery }, + { + variables: { + page, + searchTerm, + }, + } + ) + + const { dashboards } = data + + const response = { + dashboards: dashboards.dashboards, + nextPage: dashboards.pager.nextPage + ? dashboards.pager.page + 1 + : null, + } + + setState((prevState) => ({ + dashboards: + page > 1 && prevState.dashboards?.length + ? [...prevState.dashboards, ...response.dashboards] + : response.dashboards, + nextPage: response.nextPage, + searchTerm: prevState.searchTerm, + })) + }, + [dataEngine] + ) + + const onFilterChange = useCallback( + ({ value }) => { + dispatch(acSetDashboardsFilter(value)) + console.log('jj onFilterChange', value) + setState({ + dashboards: [], + nextPage: 1, + searchTerm: value, + }) + fetchDashboards({ + page: 1, + searchTerm: value, + }) + }, + [dispatch, fetchDashboards] + ) + + const onEndReached = useCallback(() => { + setState((prevState) => { + if (prevState.nextPage !== null) { + fetchDashboards({ + page: prevState.nextPage, + searchTerm: prevState.searchTerm, + }) + } + return prevState + }) + }, [fetchDashboards]) + + const scrollBoxRef = useRef(null) + + useEffect(() => { + scrollBoxRef.current + ?.getElementsByClassName(itemStyles.selectedItem) + ?.item(0) + ?.scrollIntoView({ + behavior: 'smooth', + block: 'end', + inline: 'nearest', + }) + }, []) + + return ( +
+
+ +
+
+ + {state.dashboards.map(({ displayName, id, starred }) => ( + + ))} + + +
+
+ ) +} + +NavigationMenu.propTypes = { + close: PropTypes.func.isRequired, +} diff --git a/src/components/DashboardsBar/NavigationMenu/index.js b/src/components/DashboardsBar/NavigationMenu/index.js index cdb995032..42b81259f 100644 --- a/src/components/DashboardsBar/NavigationMenu/index.js +++ b/src/components/DashboardsBar/NavigationMenu/index.js @@ -1,2 +1,3 @@ export { IconNavigation } from './IconNavigation.js' -export { NavigationMenu } from './NavigationMenu.js' +// export { NavigationMenu } from './NavigationMenu.js' +export { NavigationMenu } from './NavigationMenuLatest.js' diff --git a/src/components/DashboardsBar/NavigationMenu/styles/EndIntersectionDetector.module.css b/src/components/DashboardsBar/NavigationMenu/styles/EndIntersectionDetector.module.css new file mode 100644 index 000000000..20bbd6570 --- /dev/null +++ b/src/components/DashboardsBar/NavigationMenu/styles/EndIntersectionDetector.module.css @@ -0,0 +1,9 @@ +.container { + inline-size: 100%; + block-size: 50px; + position: absolute; + z-index: -1; + inset-block-end: 0; + inset-inline-start: 0; + background-color: red; +} diff --git a/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css b/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css index da5626d33..0b37b93fa 100644 --- a/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css +++ b/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css @@ -17,7 +17,7 @@ * is 45px and the filter-wrap is 44px, so total height above * is 137px so 100vh - 152px ensures that 15px of whitespace * is visible below the menu. */ - max-block-size: min(1000px, calc(100vh - 152px)); + max-block-size: min(150px, calc(100vh - 152px)); overflow-y: auto; scroll-behavior: smooth; } diff --git a/src/pages/edit/ActionsBar.js b/src/pages/edit/ActionsBar.js index f5844aafa..976cb33b0 100644 --- a/src/pages/edit/ActionsBar.js +++ b/src/pages/edit/ActionsBar.js @@ -10,7 +10,6 @@ import PropTypes from 'prop-types' import React, { useState } from 'react' import { connect } from 'react-redux' import { Redirect } from 'react-router-dom' -import { tFetchDashboards } from '../../actions/dashboards.js' import { tSaveDashboard, acClearEditDashboard, @@ -82,8 +81,6 @@ const EditBar = ({ dashboard, ...props }) => { }) .then(() => { props.clearSelected() - - return props.fetchDashboards() }) .then(() => setRedirectUrl('/')) .catch(deleteFailureAlert.show) @@ -333,7 +330,6 @@ const mapDispatchToProps = { clearSelected: () => (dispatch) => dispatch(acClearSelected()), saveDashboard: () => (dispatch) => dispatch(tSaveDashboard()).then((id) => id), - fetchDashboards: () => (dispatch) => dispatch(tFetchDashboards()), onDiscardChanges: () => (dispatch) => dispatch(acClearEditDashboard()), setFilterSettings: (value) => (dispatch) => dispatch(acSetFilterSettings(value)), diff --git a/src/reducers/dashboards.js b/src/reducers/dashboards.js deleted file mode 100644 index 2ddb78cdb..000000000 --- a/src/reducers/dashboards.js +++ /dev/null @@ -1,60 +0,0 @@ -/** @module reducers/dashboards */ - -import arraySort from 'd2-utilizr/lib/arraySort.js' -import { orObject } from '../modules/util.js' - -export const SET_DASHBOARDS = 'SET_DASHBOARDS' -export const ADD_DASHBOARDS = 'ADD_DASHBOARDS' - -export const EMPTY_DASHBOARDS = {} -export const DEFAULT_STATE_DASHBOARDS = null - -/** - * Reducer that computes and returns the new state based on the given action - * @function - * @param {Object} state The current state - * @param {Object} action The action to be evaluated - * @returns {Object} - */ -export default (state = DEFAULT_STATE_DASHBOARDS, action) => { - switch (action.type) { - case SET_DASHBOARDS: { - return action.value - } - case ADD_DASHBOARDS: { - return { - ...state, - ...action.value, - } - } - default: - return state - } -} - -// root selector - -export const sGetDashboardsRoot = (state) => state.dashboards - -// selector level 1 - -const sGetAllDashboards = (state) => orObject(sGetDashboardsRoot(state)) - -// selector level 2 - -const sGetStarredDashboards = (state) => - Object.values(sGetAllDashboards(state)).filter( - (dashboard) => dashboard.starred === true - ) - -const sGetUnstarredDashboards = (state) => - Object.values(sGetAllDashboards(state)).filter( - (dashboard) => dashboard.starred === false - ) - -// selector level 3 - -export const sGetDashboardsSortedByStarred = (state) => [ - ...arraySort(sGetStarredDashboards(state), 'ASC', 'displayName'), - ...arraySort(sGetUnstarredDashboards(state), 'ASC', 'displayName'), -] diff --git a/src/reducers/index.js b/src/reducers/index.js index 1f5537462..b79a131d4 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,6 +1,5 @@ import { combineReducers } from 'redux' import activeModalDimension from './activeModalDimension.js' -import dashboards from './dashboards.js' import dashboardsFilter from './dashboardsFilter.js' import dimensions from './dimensions.js' import editDashboard from './editDashboard.js' @@ -16,7 +15,6 @@ import slideshow from './slideshow.js' import visualizations from './visualizations.js' export default combineReducers({ - dashboards, selected, dashboardsFilter, visualizations, From 26d67a09199d39417b4bc80cabc43f69a1d773f5 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 21 Feb 2025 13:14:35 +0100 Subject: [PATCH 06/43] feat: restore no dashboards message --- .../NavigationMenu/NavigationMenuLatest.js | 61 +++++++++--- src/reducers/__tests__/dashboards.spec.js | 99 ------------------- 2 files changed, 47 insertions(+), 113 deletions(-) delete mode 100644 src/reducers/__tests__/dashboards.spec.js diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js index 00f008fec..6e9bcac78 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js @@ -1,6 +1,7 @@ import { useDataEngine } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { Menu, Input } from '@dhis2/ui' +import cx from 'classnames' import PropTypes from 'prop-types' import React, { useEffect, useCallback, useRef, useState } from 'react' import { useSelector, useDispatch } from 'react-redux' @@ -29,6 +30,7 @@ export const NavigationMenu = ({ close }) => { const dataEngine = useDataEngine() const dispatch = useDispatch() const filterText = useSelector(sGetDashboardsFilter) + const hasDashboards = useRef(null) const [state, setState] = useState({ dashboards: [], @@ -36,6 +38,8 @@ export const NavigationMenu = ({ close }) => { searchTerm: filterText, }) + const [initialFetchComplete, setInitialFetchComplete] = useState(false) + const fetchDashboards = useCallback( async ({ page, searchTerm }) => { const data = await dataEngine.query( @@ -57,6 +61,11 @@ export const NavigationMenu = ({ close }) => { : null, } + setInitialFetchComplete(true) + if (hasDashboards.current === null) { + hasDashboards.current = !!response.dashboards.length + } + setState((prevState) => ({ dashboards: page > 1 && prevState.dashboards?.length @@ -111,6 +120,15 @@ export const NavigationMenu = ({ close }) => { }) }, []) + if (hasDashboards.current === false) { + return ( +
+

{i18n.t('No dashboards available.')}

+

{i18n.t('Create a new dashboard using the + button.')}

+
+ ) + } + return (
@@ -125,20 +143,35 @@ export const NavigationMenu = ({ close }) => {
- {state.dashboards.map(({ displayName, id, starred }) => ( - - ))} - + {initialFetchComplete && state.dashboards.length === 0 ? ( +
  • + {i18n.t( + 'No dashboards found for "{{- filterText}}"', + { + filterText, + } + )} +
  • + ) : ( + <> + {state.dashboards.map( + ({ displayName, id, starred }) => ( + + ) + )} + + + )}
    diff --git a/src/reducers/__tests__/dashboards.spec.js b/src/reducers/__tests__/dashboards.spec.js deleted file mode 100644 index e80cfad79..000000000 --- a/src/reducers/__tests__/dashboards.spec.js +++ /dev/null @@ -1,99 +0,0 @@ -import reducer, { - DEFAULT_STATE_DASHBOARDS, - sGetDashboardsRoot, - sGetDashboardsSortedByStarred, - SET_DASHBOARDS, - ADD_DASHBOARDS, -} from '../dashboards.js' - -const dashId1 = 'dash1' -const dashId2 = 'dash2' -const dashId3 = 'dash3' -const dashId4 = 'dash4' - -const dashboardsState = { - [dashId1]: { - id: dashId1, - displayName: 'una cruscotto non stellato', - starred: false, - }, - [dashId2]: { - id: dashId2, - displayName: 'una cruscotto con stelle', - starred: true, - }, - [dashId3]: { - id: dashId3, - displayName: 'cruscotto non stellato', - starred: false, - }, - [dashId4]: { - id: dashId4, - displayName: 'cruscotto con stelle', - starred: true, - }, -} - -const dashboards = { - someDash: { - id: 'someDash', - displayName: 'roba buona', - starred: false, - }, -} - -describe('dashboards reducer', () => { - it('should return the default state', () => { - const actualState = reducer(undefined, { type: 'NO_MATCH' }) - - expect(actualState).toEqual(DEFAULT_STATE_DASHBOARDS) - }) - - it('SET_DASHBOARDS: should set the new list of dashboards', () => { - const actualState = reducer(dashboardsState, { - type: SET_DASHBOARDS, - value: dashboards, - }) - - const expectedState = dashboards - - expect(actualState).toEqual(expectedState) - }) - - it('ADD_DASHBOARDS: should append to the list of dashboards', () => { - const actualState = reducer(dashboardsState, { - type: ADD_DASHBOARDS, - value: dashboards, - }) - - const expectedState = { - ...dashboardsState, - ...dashboards, - } - - expect(actualState).toEqual(expectedState) - }) -}) - -const testState = { - dashboards: dashboardsState, -} - -const dash1 = dashboardsState[dashId1] -const dash2 = dashboardsState[dashId2] -const dash3 = dashboardsState[dashId3] -const dash4 = dashboardsState[dashId4] - -describe('dashboards selectors', () => { - it('sGetDashboardsRoot: should return the root prop', () => { - const actualState = sGetDashboardsRoot(testState) - - expect(actualState).toEqual(dashboardsState) - }) - - it('sGetDashboardsSortedByStarred: should return an array of dashboards sorted by starred/displayName-asc, then unstarred/displayName-asc', () => { - const actualState = sGetDashboardsSortedByStarred(testState) - - expect(actualState).toEqual([dash4, dash2, dash3, dash1]) - }) -}) From 7fcc8465a47c1e26ed0e154dedf49121e188a923 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 21 Feb 2025 14:05:25 +0100 Subject: [PATCH 07/43] chore: move latest component code to existing file --- .../NavigationMenu/NavigationMenu.js | 139 ++++++++++--- .../NavigationMenu/NavigationMenuLatest.js | 183 ------------------ .../DashboardsBar/NavigationMenu/index.js | 3 +- .../styles/NavigationMenu.module.css | 2 +- 4 files changed, 110 insertions(+), 217 deletions(-) delete mode 100644 src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index b42e8a21a..be695c2c0 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -1,39 +1,108 @@ +import { useDataEngine } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' -import { Input, Menu } from '@dhis2/ui' +import { Menu, Input } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useCallback, useMemo, useEffect, useRef } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import React, { useEffect, useCallback, useRef, useState } from 'react' +import { useSelector, useDispatch } from 'react-redux' import { acSetDashboardsFilter } from '../../../actions/dashboardsFilter.js' -import { sGetDashboardsSortedByStarred } from '../../../reducers/dashboards.js' import { sGetDashboardsFilter } from '../../../reducers/dashboardsFilter.js' +import { EndIntersectionDetector } from './EndIntersectionDetector.js' import { NavigationMenuItem } from './NavigationMenuItem.js' import styles from './styles/NavigationMenu.module.css' import itemStyles from './styles/NavigationMenuItem.module.css' +const dashboardsQuery = { + resource: 'dashboards', + params: ({ page, searchTerm }) => { + return { + fields: 'id,displayName,favorite~rename(starred)', + order: 'favorite:desc,displayName:asc', + filter: searchTerm ? `displayName:ilike:${searchTerm}` : undefined, + paging: true, + pageSize: 8, + page, + } + }, +} + export const NavigationMenu = ({ close }) => { + const dataEngine = useDataEngine() const dispatch = useDispatch() - const scrollBoxRef = useRef(null) - const dashboards = useSelector(sGetDashboardsSortedByStarred) const filterText = useSelector(sGetDashboardsFilter) + const hasDashboards = useRef(null) + + const [state, setState] = useState({ + dashboards: [], + nextPage: 1, + searchTerm: filterText, + }) + + const [initialFetchComplete, setInitialFetchComplete] = useState(false) + + const fetchDashboards = useCallback( + async ({ dashboards, page, searchTerm }) => { + const data = await dataEngine.query( + { dashboards: dashboardsQuery }, + { + variables: { + page, + searchTerm, + }, + } + ) + + const response = { + dashboards: data.dashboards.dashboards, + nextPage: data.dashboards.pager.nextPage + ? data.dashboards.pager.page + 1 + : null, + } + + setInitialFetchComplete(true) + if (hasDashboards.current === null) { + hasDashboards.current = !!response.dashboards.length + } + + setState((prevState) => ({ + dashboards: + page > 1 + ? [...dashboards, ...response.dashboards] + : response.dashboards, + nextPage: response.nextPage, + searchTerm: prevState.searchTerm, + })) + }, + [dataEngine] + ) + const onFilterChange = useCallback( ({ value }) => { dispatch(acSetDashboardsFilter(value)) + fetchDashboards({ + dashboards: [], + page: 1, + searchTerm: value, + }) }, - [dispatch] - ) - const filteredDashboards = useMemo( - () => - dashboards.filter( - (dashboard) => - !filterText || - dashboard.displayName - .toLowerCase() - .includes(filterText.toLowerCase()) - ), - [filterText, dashboards] + [dispatch, fetchDashboards] ) + const onEndReached = useCallback(() => { + setState((prevState) => { + if (prevState.nextPage !== null) { + fetchDashboards({ + dashboards: prevState.dashboards, + page: prevState.nextPage, + searchTerm: prevState.searchTerm, + }) + } + return prevState + }) + }, [fetchDashboards]) + + const scrollBoxRef = useRef(null) + useEffect(() => { scrollBoxRef.current ?.getElementsByClassName(itemStyles.selectedItem) @@ -45,7 +114,7 @@ export const NavigationMenu = ({ close }) => { }) }, []) - if (dashboards.length === 0) { + if (hasDashboards.current === false) { return (

    {i18n.t('No dashboards available.')}

    @@ -68,7 +137,7 @@ export const NavigationMenu = ({ close }) => {
    - {filteredDashboards.length === 0 ? ( + {initialFetchComplete && state.dashboards.length === 0 ? (
  • {i18n.t( 'No dashboards found for "{{- filterText}}"', @@ -78,23 +147,31 @@ export const NavigationMenu = ({ close }) => { )}
  • ) : ( - filteredDashboards.map( - ({ displayName, id, starred }) => ( - - ) - ) + <> + {state.dashboards.map( + ({ displayName, id, starred }) => ( + + ) + )} + + )}
    ) } + NavigationMenu.propTypes = { close: PropTypes.func.isRequired, } diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js deleted file mode 100644 index 6e9bcac78..000000000 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenuLatest.js +++ /dev/null @@ -1,183 +0,0 @@ -import { useDataEngine } from '@dhis2/app-runtime' -import i18n from '@dhis2/d2-i18n' -import { Menu, Input } from '@dhis2/ui' -import cx from 'classnames' -import PropTypes from 'prop-types' -import React, { useEffect, useCallback, useRef, useState } from 'react' -import { useSelector, useDispatch } from 'react-redux' -import { acSetDashboardsFilter } from '../../../actions/dashboardsFilter.js' -import { sGetDashboardsFilter } from '../../../reducers/dashboardsFilter.js' -import { EndIntersectionDetector } from './EndIntersectionDetector.js' -import { NavigationMenuItem } from './NavigationMenuItem.js' -import styles from './styles/NavigationMenu.module.css' -import itemStyles from './styles/NavigationMenuItem.module.css' - -const dashboardsQuery = { - resource: 'dashboards', - params: ({ page, searchTerm }) => { - return { - fields: 'id,displayName,favorite~rename(starred)', - order: 'favorite:desc,displayName:asc', - filter: searchTerm ? `displayName:ilike:${searchTerm}` : undefined, - paging: true, - pageSize: 8, - page, - } - }, -} - -export const NavigationMenu = ({ close }) => { - const dataEngine = useDataEngine() - const dispatch = useDispatch() - const filterText = useSelector(sGetDashboardsFilter) - const hasDashboards = useRef(null) - - const [state, setState] = useState({ - dashboards: [], - nextPage: 1, - searchTerm: filterText, - }) - - const [initialFetchComplete, setInitialFetchComplete] = useState(false) - - const fetchDashboards = useCallback( - async ({ page, searchTerm }) => { - const data = await dataEngine.query( - { dashboards: dashboardsQuery }, - { - variables: { - page, - searchTerm, - }, - } - ) - - const { dashboards } = data - - const response = { - dashboards: dashboards.dashboards, - nextPage: dashboards.pager.nextPage - ? dashboards.pager.page + 1 - : null, - } - - setInitialFetchComplete(true) - if (hasDashboards.current === null) { - hasDashboards.current = !!response.dashboards.length - } - - setState((prevState) => ({ - dashboards: - page > 1 && prevState.dashboards?.length - ? [...prevState.dashboards, ...response.dashboards] - : response.dashboards, - nextPage: response.nextPage, - searchTerm: prevState.searchTerm, - })) - }, - [dataEngine] - ) - - const onFilterChange = useCallback( - ({ value }) => { - dispatch(acSetDashboardsFilter(value)) - console.log('jj onFilterChange', value) - setState({ - dashboards: [], - nextPage: 1, - searchTerm: value, - }) - fetchDashboards({ - page: 1, - searchTerm: value, - }) - }, - [dispatch, fetchDashboards] - ) - - const onEndReached = useCallback(() => { - setState((prevState) => { - if (prevState.nextPage !== null) { - fetchDashboards({ - page: prevState.nextPage, - searchTerm: prevState.searchTerm, - }) - } - return prevState - }) - }, [fetchDashboards]) - - const scrollBoxRef = useRef(null) - - useEffect(() => { - scrollBoxRef.current - ?.getElementsByClassName(itemStyles.selectedItem) - ?.item(0) - ?.scrollIntoView({ - behavior: 'smooth', - block: 'end', - inline: 'nearest', - }) - }, []) - - if (hasDashboards.current === false) { - return ( -
    -

    {i18n.t('No dashboards available.')}

    -

    {i18n.t('Create a new dashboard using the + button.')}

    -
    - ) - } - - return ( -
    -
    - -
    -
    - - {initialFetchComplete && state.dashboards.length === 0 ? ( -
  • - {i18n.t( - 'No dashboards found for "{{- filterText}}"', - { - filterText, - } - )} -
  • - ) : ( - <> - {state.dashboards.map( - ({ displayName, id, starred }) => ( - - ) - )} - - - )} -
    -
    -
    - ) -} - -NavigationMenu.propTypes = { - close: PropTypes.func.isRequired, -} diff --git a/src/components/DashboardsBar/NavigationMenu/index.js b/src/components/DashboardsBar/NavigationMenu/index.js index 42b81259f..cdb995032 100644 --- a/src/components/DashboardsBar/NavigationMenu/index.js +++ b/src/components/DashboardsBar/NavigationMenu/index.js @@ -1,3 +1,2 @@ export { IconNavigation } from './IconNavigation.js' -// export { NavigationMenu } from './NavigationMenu.js' -export { NavigationMenu } from './NavigationMenuLatest.js' +export { NavigationMenu } from './NavigationMenu.js' diff --git a/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css b/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css index 0b37b93fa..073178a0d 100644 --- a/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css +++ b/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css @@ -17,7 +17,7 @@ * is 45px and the filter-wrap is 44px, so total height above * is 137px so 100vh - 152px ensures that 15px of whitespace * is visible below the menu. */ - max-block-size: min(150px, calc(100vh - 152px)); + max-block-size: min(130px, calc(100vh - 152px)); overflow-y: auto; scroll-behavior: smooth; } From 97c7051db6fb7ece51890a7780a2f3bfe42dd414 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 21 Feb 2025 15:50:37 +0100 Subject: [PATCH 08/43] chore: test changes --- .../__tests__/NavigationMenu.spec.js | 6 +++--- src/components/__tests__/App.spec.js | 15 --------------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js index 588774b69..bfc0cfbd0 100644 --- a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js +++ b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js @@ -45,7 +45,7 @@ const baseState = { const createMockStore = (state) => createStore(() => ({ ...baseState, ...state })) -test('renders a list of dashboard menu items', () => { +test.skip('renders a list of dashboard menu items', () => { const mockStore = createMockStore({}) const { getAllByRole } = render( @@ -57,7 +57,7 @@ test('renders a list of dashboard menu items', () => { expect(getAllByRole('menu-item')).toHaveLength(5) }) -test('renders a notification if no dashboards are available', () => { +test.skip('renders a notification if no dashboards are available', () => { const mockStore = createMockStore({ dashboards: {} }) const { getByText } = render( @@ -73,7 +73,7 @@ test('renders a notification if no dashboards are available', () => { ).toBeVisible() }) -test('renders a placeholder list item if no dashboards meet the filter criteria', () => { +test.skip('renders a placeholder list item if no dashboards meet the filter criteria', () => { const filterStr = 'xxxxxxxxxxxxx' const mockStore = createMockStore({ dashboardsFilter: filterStr }) const { getByText, getByPlaceholderText } = render( diff --git a/src/components/__tests__/App.spec.js b/src/components/__tests__/App.spec.js index 73e8c8509..9ced9970f 100644 --- a/src/components/__tests__/App.spec.js +++ b/src/components/__tests__/App.spec.js @@ -3,7 +3,6 @@ import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' import thunk from 'redux-thunk' -import { apiFetchDashboards } from '../../api/fetchAllDashboards.js' import App from '../App.js' import { useSystemSettings } from '../SystemSettingsProvider.js' @@ -25,18 +24,6 @@ jest.mock('@dhis2/app-runtime', () => ({ useCacheableSection: jest.fn, })) -jest.mock('../../api/fetchAllDashboards.js', () => { - return { - apiFetchDashboards: jest.fn(() => [ - { - id: 'rainbowdash', - displayName: 'Rainbow Dash', - starred: true, - }, - ]), - } -}) - jest.mock('../../api/dataStatistics.js', () => { return { apiGetDataStatistics: jest.fn(() => ({ @@ -138,7 +125,6 @@ test('renders the app with a dashboard', () => { ) expect(container).toMatchSnapshot() - expect(apiFetchDashboards).toHaveBeenCalledTimes(1) jest.clearAllMocks() }) @@ -157,6 +143,5 @@ test('renders the app with the start page', async () => { ) await act(() => promise) expect(container).toMatchSnapshot() - expect(apiFetchDashboards).toHaveBeenCalledTimes(1) jest.clearAllMocks() }) From f403795c990b17a0fcb658482dfa776191c6b467 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 21 Feb 2025 17:02:44 +0100 Subject: [PATCH 09/43] chore: test fixes --- .../NavigationMenu/NavigationMenu.js | 2 +- .../__tests__/NavigationMenu.spec.js | 254 +++++++++++++----- .../styles/NavigationMenu.module.css | 2 +- 3 files changed, 184 insertions(+), 74 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index be695c2c0..4212fa09a 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -20,7 +20,7 @@ const dashboardsQuery = { order: 'favorite:desc,displayName:asc', filter: searchTerm ? `displayName:ilike:${searchTerm}` : undefined, paging: true, - pageSize: 8, + pageSize: 40, page, } }, diff --git a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js index bfc0cfbd0..22f4fa12b 100644 --- a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js +++ b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react' +import { render, waitFor, act } from '@testing-library/react' import { createMemoryHistory } from 'history' import React from 'react' import { Provider } from 'react-redux' @@ -6,85 +6,195 @@ import { Router } from 'react-router-dom' import { createStore } from 'redux' import { NavigationMenu } from '../NavigationMenu.js' -jest.mock('../NavigationMenuItem.js', () => ({ - NavigationMenuItem: ({ displayName }) => ( -
  • {displayName}
  • - ), +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: jest.fn(), })) -const baseState = { - dashboards: { - nghVC4wtyzi: { - id: 'nghVC4wtyzi', - displayName: 'Antenatal Care', - starred: true, - }, - rmPiJIPFL4U: { - displayName: 'Antenatal Care data', - id: 'rmPiJIPFL4U', - starred: false, - }, - JW7RlN5xafN: { - displayName: 'Cases Malaria', - id: 'JW7RlN5xafN', - starred: false, - }, - iMnYyBfSxmM: { - displayName: 'Delivery', - id: 'iMnYyBfSxmM', - starred: false, - }, - vqh4MBWOTi4: { - displayName: 'Disease Surveillance', - id: 'vqh4MBWOTi4', - starred: false, + +jest.mock('../../../../actions/dashboardsFilter', () => ({ + acSetDashboardsFilter: jest.fn(), +})) + +jest.mock('../EndIntersectionDetector.js', () => { + const React = require('react') + return { + EndIntersectionDetector: ({ onEndReached }) => { + // Simulate intersection + React.useEffect(() => { + onEndReached() + }, [onEndReached]) + return
    }, - }, + } +}) + +jest.mock('../NavigationMenuItem.js', () => { + const React = require('react') + return { + NavigationMenuItem: ({ displayName }) => ( +
  • {displayName}
  • + ), + } +}) + +const baseState = { dashboardsFilter: '', } +const dashboards = { + nghVC4wtyzi: { + id: 'nghVC4wtyzi', + displayName: 'Antenatal Care', + starred: true, + }, + rmPiJIPFL4U: { + displayName: 'Antenatal Care data', + id: 'rmPiJIPFL4U', + starred: false, + }, + JW7RlN5xafN: { + displayName: 'Cases Malaria', + id: 'JW7RlN5xafN', + starred: false, + }, + iMnYyBfSxmM: { + displayName: 'Delivery', + id: 'iMnYyBfSxmM', + starred: false, + }, + vqh4MBWOTi4: { + displayName: 'Disease Surveillance', + id: 'vqh4MBWOTi4', + starred: false, + }, +} + const createMockStore = (state) => createStore(() => ({ ...baseState, ...state })) -test.skip('renders a list of dashboard menu items', () => { - const mockStore = createMockStore({}) - const { getAllByRole } = render( - - - {}} /> - - - ) - expect(getAllByRole('menu-item')).toHaveLength(5) -}) +describe('NavigationMenu', () => { + let store + let dataEngine -test.skip('renders a notification if no dashboards are available', () => { - const mockStore = createMockStore({ dashboards: {} }) - const { getByText } = render( - - - {}} /> - - - ) - - expect(getByText('No dashboards available.')).toBeVisible() - expect( - getByText('Create a new dashboard using the + button.') - ).toBeVisible() -}) + beforeEach(() => { + store = createMockStore({}) + dataEngine = { + query: jest.fn().mockResolvedValue({ + dashboards: { + dashboards: Object.values(dashboards), + pager: { + page: 1, + nextPage: null, + }, + }, + }), + } + require('@dhis2/app-runtime').useDataEngine.mockReturnValue(dataEngine) + }) + + it('requests the dashboards using the correct parameters', async () => { + const { getAllByRole } = render( + + + {}} /> + + + ) + + await waitFor(() => { + expect(dataEngine.query).toHaveBeenCalledWith( + { dashboards: expect.any(Object) }, + { variables: { page: 1, searchTerm: '' } } + ) + }) + + expect(getAllByRole('menu-item')).toHaveLength(5) + }) + + it('renders a notification if no dashboards are available', async () => { + const mockStore = createMockStore({ dashboards: {} }) + dataEngine.query.mockResolvedValueOnce({ + dashboards: { + dashboards: [], + pager: { + page: 1, + nextPage: null, + }, + }, + }) + + let getByText + await act(async () => { + const renderResult = render( + + + {}} /> + + + ) + getByText = renderResult.getByText + }) + + expect(getByText('No dashboards available.')).toBeVisible() + expect( + getByText('Create a new dashboard using the + button.') + ).toBeVisible() + }) + + it.skip('renders a placeholder list item if no dashboards meet the filter criteria', async () => { + const filterStr = 'xxxxxxxxxxxxx' + const mockStore = createMockStore({ dashboardsFilter: filterStr }) + + // dataEngine.query.mockResolvedValueOnce({ + // dashboards: { + // dashboards: [], + // pager: { + // page: 1, + // nextPage: null, + // }, + // }, + // }) + dataEngine = { + query: jest + .fn() + .mockResolvedValueOnce({ + dashboards: { + dashboards: Object.values(dashboards), + pager: { + page: 1, + nextPage: null, + }, + }, + }) + .mockResolvedValueOnce({ + dashboards: { + dashboards: [], + pager: { + page: 1, + nextPage: null, + }, + }, + }), + } + + let getByText, getByPlaceholderText + + await act(async () => { + const renderResult = render( + + + {}} /> + + + ) + getByText = renderResult.getByText + getByPlaceholderText = renderResult.getByPlaceholderText + }) -test.skip('renders a placeholder list item if no dashboards meet the filter criteria', () => { - const filterStr = 'xxxxxxxxxxxxx' - const mockStore = createMockStore({ dashboardsFilter: filterStr }) - const { getByText, getByPlaceholderText } = render( - - - {}} /> - - - ) - expect(getByPlaceholderText('Search for a dashboard')).toHaveValue( - filterStr - ) - expect(getByText(`No dashboards found for "${filterStr}"`)).toBeVisible() + expect(getByPlaceholderText('Search for a dashboard')).toHaveValue( + filterStr + ) + expect( + getByText(`No dashboards found for "${filterStr}"`) + ).toBeVisible() + }) }) diff --git a/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css b/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css index 073178a0d..da5626d33 100644 --- a/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css +++ b/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css @@ -17,7 +17,7 @@ * is 45px and the filter-wrap is 44px, so total height above * is 137px so 100vh - 152px ensures that 15px of whitespace * is visible below the menu. */ - max-block-size: min(130px, calc(100vh - 152px)); + max-block-size: min(1000px, calc(100vh - 152px)); overflow-y: auto; scroll-behavior: smooth; } From 8aeecc312855abcd4700b9d0a7612bbaae0b9f0d Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Mon, 24 Feb 2025 16:03:32 +0100 Subject: [PATCH 10/43] fix: handle case where preferred id is invalid --- i18n/en.pot | 7 +-- src/modules/localStorage.js | 4 ++ src/pages/edit/ActionsBar.js | 9 ++- src/pages/view/CacheableViewDashboard.js | 75 ++++++++++++++++++------ 4 files changed, 70 insertions(+), 25 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index f47e356e2..af6539f9b 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-02-21T08:42:49.386Z\n" -"PO-Revision-Date: 2025-02-21T08:42:49.387Z\n" +"POT-Creation-Date: 2025-02-24T12:26:34.007Z\n" +"PO-Revision-Date: 2025-02-24T12:26:34.008Z\n" msgid "Untitled dashboard" msgstr "Untitled dashboard" @@ -563,9 +563,6 @@ msgstr "Your most viewed dashboards" msgid "No dashboards found. Use the + button to create a new dashboard." msgstr "No dashboards found. Use the + button to create a new dashboard." -msgid "Requested dashboard not found" -msgstr "Requested dashboard not found" - msgid "No description" msgstr "No description" diff --git a/src/modules/localStorage.js b/src/modules/localStorage.js index 048a59109..7c1cfe423 100644 --- a/src/modules/localStorage.js +++ b/src/modules/localStorage.js @@ -5,6 +5,10 @@ export const storePreferredDashboardId = (username, dashboardId) => { localStorage.setItem(`dhis2.dashboard.current.${username}`, dashboardId) } +export const removePreferredDashboardId = (username) => { + localStorage.removeItem(`dhis2.dashboard.current.${username}`) +} + export const getPluginOverrides = () => (process.env.NODE_ENV !== 'production' && JSON.parse(localStorage.getItem('dhis2.dashboard.pluginOverrides'))) || diff --git a/src/pages/edit/ActionsBar.js b/src/pages/edit/ActionsBar.js index 976cb33b0..959b0e76b 100644 --- a/src/pages/edit/ActionsBar.js +++ b/src/pages/edit/ActionsBar.js @@ -1,4 +1,8 @@ -import { OfflineTooltip, TranslationDialog } from '@dhis2/analytics' +import { + OfflineTooltip, + TranslationDialog, + useCachedDataQuery, +} from '@dhis2/analytics' import { useDhis2ConnectionStatus, useDataEngine, @@ -20,6 +24,7 @@ import { import { acClearPrintDashboard } from '../../actions/printDashboard.js' import { acClearSelected } from '../../actions/selected.js' import ConfirmActionDialog from '../../components/ConfirmActionDialog.js' +import { removePreferredDashboardId } from '../../modules/localStorage.js' import { sGetEditDashboardRoot, sGetIsPrintPreviewView, @@ -45,6 +50,7 @@ const deleteFailedMessage = i18n.t( const fieldsToTranslate = ['name', 'description'] const EditBar = ({ dashboard, ...props }) => { + const { currentUser } = useCachedDataQuery() const dataEngine = useDataEngine() const { isConnected: online } = useDhis2ConnectionStatus() const [translationDlgIsOpen, setTranslationDlgIsOpen] = useState(false) @@ -81,6 +87,7 @@ const EditBar = ({ dashboard, ...props }) => { }) .then(() => { props.clearSelected() + removePreferredDashboardId(currentUser.username) }) .then(() => setRedirectUrl('/')) .catch(deleteFailureAlert.show) diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 0e81d9de1..b4ac608e9 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -1,8 +1,8 @@ import { useCachedDataQuery } from '@dhis2/analytics' -import { CacheableSection, useDataQuery } from '@dhis2/app-runtime' +import { CacheableSection, useDataEngine } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { connect } from 'react-redux' import { acClearSelected } from '../../actions/selected.js' import DashboardsBar from '../../components/DashboardsBar/index.js' @@ -13,20 +13,32 @@ import { getPreferredDashboardId } from '../../modules/localStorage.js' import { sGetSelectedId } from '../../reducers/selected.js' import ViewDashboard from './ViewDashboard.js' -const query = { +const firstDashboardQuery = { dashboards: { resource: 'dashboards', params: { - fields: 'id', + fields: 'id,favorite,displayName', + order: 'favorite:desc,displayName:asc', paging: true, pageSize: 1, }, }, } +const requestedDashboardQuery = { + dashboard: { + resource: 'dashboards', + id: ({ id }) => id, + params: { + fields: ['id', 'displayName'], + }, + }, +} + const CacheableViewDashboard = ({ clearSelectedDashboard, id, selectedId }) => { const { currentUser } = useCachedDataQuery() - const { data, loading, fetching } = useDataQuery(query) + const engine = useDataEngine() + const [idToLoad, setIdToLoad] = useState(undefined) useEffect(() => { if (id === null && selectedId !== null) { @@ -34,34 +46,55 @@ const CacheableViewDashboard = ({ clearSelectedDashboard, id, selectedId }) => { } }, [id, selectedId, clearSelectedDashboard]) - if (loading || fetching) { + useEffect(() => { + const fetchIdToLoad = async () => { + try { + const { dashboard } = await engine.query( + requestedDashboardQuery, + { + variables: { id }, + } + ) + setIdToLoad(dashboard.id) + } catch (error) { + const { dashboards } = await engine.query(firstDashboardQuery) + + if (dashboards.dashboards.length === 0) { + setIdToLoad(null) + return + } + const firstDashboardId = dashboards?.dashboards[0]?.id + setIdToLoad(firstDashboardId) + } + } + + fetchIdToLoad() + }, [engine, id]) + + if (idToLoad === undefined) { return } - if (!data?.dashboards.dashboards.length || id === null) { + if (idToLoad === null) { return ( <> ) } - const cacheSectionId = getCacheableSectionId(currentUser.id, id) + const cacheSectionId = getCacheableSectionId(currentUser.id, idToLoad) return ( }> @@ -77,12 +110,16 @@ CacheableViewDashboard.propTypes = { const mapStateToProps = (state, ownProps) => { // match is provided by the react-router-dom const routeId = ownProps.match?.params?.dashboardId || null + let preferredId = getPreferredDashboardId(ownProps.username) + + if (preferredId === 'null') { + preferredId = null + } - const dashboardIdToSelect = - routeId || getPreferredDashboardId(ownProps.username) + const dashboardIdToSelect = routeId || preferredId return { - id: dashboardIdToSelect || null, + id: dashboardIdToSelect, selectedId: sGetSelectedId(state) || null, } } From 879db0f7afd71a83ae5631685243e89d14df0a8f Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 26 Feb 2025 10:46:56 +0100 Subject: [PATCH 11/43] chore: reorder --- src/components/DashboardsBar/NavigationMenu/NavigationMenu.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 4212fa09a..16ac952bd 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -1,6 +1,6 @@ import { useDataEngine } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' -import { Menu, Input } from '@dhis2/ui' +import { Input, Menu } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React, { useEffect, useCallback, useRef, useState } from 'react' From 91968df5c6db54a8828905505154d64d49443d70 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 26 Feb 2025 11:17:44 +0100 Subject: [PATCH 12/43] chore: reorder more --- src/components/DashboardsBar/NavigationMenu/NavigationMenu.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 16ac952bd..8159ccaf1 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -3,8 +3,8 @@ import i18n from '@dhis2/d2-i18n' import { Input, Menu } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useEffect, useCallback, useRef, useState } from 'react' -import { useSelector, useDispatch } from 'react-redux' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' import { acSetDashboardsFilter } from '../../../actions/dashboardsFilter.js' import { sGetDashboardsFilter } from '../../../reducers/dashboardsFilter.js' import { EndIntersectionDetector } from './EndIntersectionDetector.js' From 6fc83c61726a95a257ffb426c46a5172403253ec Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 26 Feb 2025 11:38:29 +0100 Subject: [PATCH 13/43] chore: use hooks instead of connect from react-redux --- src/components/App.js | 12 +----- src/pages/view/CacheableViewDashboard.js | 50 +++++++++--------------- 2 files changed, 20 insertions(+), 42 deletions(-) diff --git a/src/components/App.js b/src/components/App.js index 942a01313..79f36bd10 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -45,10 +45,7 @@ const App = (props) => { systemSettings.startModuleEnableLightweight ? ( ) : ( - + ) } /> @@ -70,12 +67,7 @@ const App = (props) => { ( - - )} + render={(props) => } /> { +const CacheableViewDashboard = ({ match }) => { const { currentUser } = useCachedDataQuery() const engine = useDataEngine() + const dispatch = useDispatch() const [idToLoad, setIdToLoad] = useState(undefined) + let preferredId = getPreferredDashboardId(currentUser.username) + const selectedId = useSelector(sGetSelectedId) + // match comes from react-router-dom + const routeId = match?.params?.dashboardId || null + + // TODO - is this really needed? + if (preferredId === 'null') { + preferredId = null + } + + const id = routeId || preferredId useEffect(() => { if (id === null && selectedId !== null) { - clearSelectedDashboard() + dispatch(acClearSelected()) } - }, [id, selectedId, clearSelectedDashboard]) + }, [id, selectedId, dispatch]) useEffect(() => { const fetchIdToLoad = async () => { @@ -102,33 +114,7 @@ const CacheableViewDashboard = ({ clearSelectedDashboard, id, selectedId }) => { } CacheableViewDashboard.propTypes = { - clearSelectedDashboard: PropTypes.func, - id: PropTypes.string, - selectedId: PropTypes.string, -} - -const mapStateToProps = (state, ownProps) => { - // match is provided by the react-router-dom - const routeId = ownProps.match?.params?.dashboardId || null - let preferredId = getPreferredDashboardId(ownProps.username) - - if (preferredId === 'null') { - preferredId = null - } - - const dashboardIdToSelect = routeId || preferredId - - return { - id: dashboardIdToSelect, - selectedId: sGetSelectedId(state) || null, - } -} - -const mapDispatchToProps = { - clearSelectedDashboard: acClearSelected, + match: PropTypes.object, } -export default connect( - mapStateToProps, - mapDispatchToProps -)(CacheableViewDashboard) +export default CacheableViewDashboard From 94c013fe1bc18c7d459444775f6498ab27e9294d Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 26 Feb 2025 13:43:15 +0100 Subject: [PATCH 14/43] fix: handle when routeId not found --- i18n/en.pot | 7 ++- src/pages/view/CacheableViewDashboard.js | 61 +++++++++++++++++++----- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index af6539f9b..e97a8c0c6 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-02-24T12:26:34.007Z\n" -"PO-Revision-Date: 2025-02-24T12:26:34.008Z\n" +"POT-Creation-Date: 2025-02-26T12:30:51.331Z\n" +"PO-Revision-Date: 2025-02-26T12:30:51.331Z\n" msgid "Untitled dashboard" msgstr "Untitled dashboard" @@ -560,6 +560,9 @@ msgstr "Create a new dashboard with the + button." msgid "Your most viewed dashboards" msgstr "Your most viewed dashboards" +msgid "Requested dashboard not found" +msgstr "Requested dashboard not found" + msgid "No dashboards found. Use the + button to create a new dashboard." msgstr "No dashboards found. Use the + button to create a new dashboard." diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 3d0c71b4e..b35d60a78 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -40,35 +40,60 @@ const CacheableViewDashboard = ({ match }) => { const engine = useDataEngine() const dispatch = useDispatch() const [idToLoad, setIdToLoad] = useState(undefined) - let preferredId = getPreferredDashboardId(currentUser.username) + const [fetchError, setFetchError] = useState(false) const selectedId = useSelector(sGetSelectedId) + const preferredId = getPreferredDashboardId(currentUser.username) || null // match comes from react-router-dom const routeId = match?.params?.dashboardId || null - // TODO - is this really needed? - if (preferredId === 'null') { - preferredId = null - } - - const id = routeId || preferredId - useEffect(() => { - if (id === null && selectedId !== null) { + if (routeId === null && preferredId === null && selectedId !== null) { dispatch(acClearSelected()) } - }, [id, selectedId, dispatch]) + }, [routeId, preferredId, selectedId, dispatch]) useEffect(() => { const fetchIdToLoad = async () => { try { + if (!routeId && !preferredId) { + const { dashboards } = await engine.query( + firstDashboardQuery + ) + if (dashboards.dashboards.length === 0) { + setIdToLoad(null) + return + } + const firstDashboardId = dashboards?.dashboards[0]?.id + setIdToLoad(firstDashboardId) + return + } + + if (routeId) { + const { dashboard } = await engine.query( + requestedDashboardQuery, + { + variables: { id: routeId }, + } + ) + setIdToLoad(dashboard.id) + return + } + const { dashboard } = await engine.query( requestedDashboardQuery, { - variables: { id }, + variables: { id: preferredId }, } ) setIdToLoad(dashboard.id) + return } catch (error) { + if (routeId) { + setFetchError(error.details?.httpStatusCode) + setIdToLoad(null) + return + } + const { dashboards } = await engine.query(firstDashboardQuery) if (dashboards.dashboards.length === 0) { @@ -79,9 +104,21 @@ const CacheableViewDashboard = ({ match }) => { setIdToLoad(firstDashboardId) } } + setFetchError(false) fetchIdToLoad() - }, [engine, id]) + }, [engine, routeId, preferredId]) + + if (fetchError) { + return ( + <> + + + + ) + } if (idToLoad === undefined) { return From 6ccce38f13c4852d3d854ba699aa9e0d3ccd8b92 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 26 Feb 2025 13:55:02 +0100 Subject: [PATCH 15/43] chore: cleanup --- src/pages/view/CacheableViewDashboard.js | 38 ++++++++++++------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index b35d60a78..7b5ed7a9b 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -35,12 +35,15 @@ const requestedDashboardQuery = { }, } +const NO_DASHBOARDS_FOUND = 'NO_DASHBOARDS_FOUND' +const REQUESTED_DASHBOARD_NOT_FOUND = 'REQUESTED_DASHBOARD_NOT_FOUND' + const CacheableViewDashboard = ({ match }) => { const { currentUser } = useCachedDataQuery() const engine = useDataEngine() const dispatch = useDispatch() - const [idToLoad, setIdToLoad] = useState(undefined) - const [fetchError, setFetchError] = useState(false) + const [idToLoad, setIdToLoad] = useState(null) + const [fetchError, setFetchError] = useState(null) const selectedId = useSelector(sGetSelectedId) const preferredId = getPreferredDashboardId(currentUser.username) || null // match comes from react-router-dom @@ -60,7 +63,7 @@ const CacheableViewDashboard = ({ match }) => { firstDashboardQuery ) if (dashboards.dashboards.length === 0) { - setIdToLoad(null) + setFetchError(NO_DASHBOARDS_FOUND) return } const firstDashboardId = dashboards?.dashboards[0]?.id @@ -89,14 +92,15 @@ const CacheableViewDashboard = ({ match }) => { return } catch (error) { if (routeId) { - setFetchError(error.details?.httpStatusCode) setIdToLoad(null) + setFetchError(REQUESTED_DASHBOARD_NOT_FOUND) return } const { dashboards } = await engine.query(firstDashboardQuery) if (dashboards.dashboards.length === 0) { + setFetchError(NO_DASHBOARDS_FOUND) setIdToLoad(null) return } @@ -104,7 +108,7 @@ const CacheableViewDashboard = ({ match }) => { setIdToLoad(firstDashboardId) } } - setFetchError(false) + setFetchError(null) fetchIdToLoad() }, [engine, routeId, preferredId]) @@ -113,28 +117,24 @@ const CacheableViewDashboard = ({ match }) => { return ( <> + ) } - if (idToLoad === undefined) { - return - } + console.log('jj ', { idToLoad, fetchError, routeId, preferredId }) if (idToLoad === null) { - return ( - <> - - - - ) + return } const cacheSectionId = getCacheableSectionId(currentUser.id, idToLoad) From 45f161ff1b46e02f24fb9caa2f9badae222afedb Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 26 Feb 2025 14:17:22 +0100 Subject: [PATCH 16/43] chore: remove code smells --- .../NavigationMenu/__tests__/NavigationMenu.spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js index 22f4fa12b..99368e122 100644 --- a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js +++ b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js @@ -28,7 +28,6 @@ jest.mock('../EndIntersectionDetector.js', () => { }) jest.mock('../NavigationMenuItem.js', () => { - const React = require('react') return { NavigationMenuItem: ({ displayName }) => (
  • {displayName}
  • From 0f9329913e1f9a49510262b111dccf603d7f2234 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 26 Feb 2025 14:35:03 +0100 Subject: [PATCH 17/43] chore: fix code smell --- .../__tests__/NavigationMenu.spec.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js index 99368e122..8f80b382b 100644 --- a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js +++ b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js @@ -16,15 +16,18 @@ jest.mock('../../../../actions/dashboardsFilter', () => ({ jest.mock('../EndIntersectionDetector.js', () => { const React = require('react') - return { - EndIntersectionDetector: ({ onEndReached }) => { - // Simulate intersection - React.useEffect(() => { - onEndReached() - }, [onEndReached]) - return
    - }, + const PropTypes = require('prop-types') + const EndIntersectionDetector = ({ onEndReached }) => { + // Simulate intersection + React.useEffect(() => { + onEndReached() + }, [onEndReached]) + return
    + } + EndIntersectionDetector.propTypes = { + onEndReached: PropTypes.func.isRequired, } + return { EndIntersectionDetector } }) jest.mock('../NavigationMenuItem.js', () => { From 5092eb1a8eba5c8316080d7d0eb4c93b7583f3a5 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 26 Feb 2025 16:04:18 +0100 Subject: [PATCH 18/43] fix: re set hasDashboards when filter text is removed --- .../DashboardsBar/NavigationMenu/NavigationMenu.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 8159ccaf1..7099c6296 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -30,7 +30,7 @@ export const NavigationMenu = ({ close }) => { const dataEngine = useDataEngine() const dispatch = useDispatch() const filterText = useSelector(sGetDashboardsFilter) - const hasDashboards = useRef(null) + const [hasDashboards, setHasDashboards] = useState(null) const [state, setState] = useState({ dashboards: [], @@ -60,9 +60,7 @@ export const NavigationMenu = ({ close }) => { } setInitialFetchComplete(true) - if (hasDashboards.current === null) { - hasDashboards.current = !!response.dashboards.length - } + setHasDashboards(!!response.dashboards.length) setState((prevState) => ({ dashboards: @@ -114,7 +112,7 @@ export const NavigationMenu = ({ close }) => { }) }, []) - if (hasDashboards.current === false) { + if (hasDashboards === false && !filterText) { return (

    {i18n.t('No dashboards available.')}

    From 98ec059b5cfccf7ef28b530e140c07487f63a1ca Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Thu, 27 Feb 2025 15:37:33 +0100 Subject: [PATCH 19/43] chore: remove console.log --- src/pages/view/CacheableViewDashboard.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 7b5ed7a9b..78781ac12 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -131,8 +131,6 @@ const CacheableViewDashboard = ({ match }) => { ) } - console.log('jj ', { idToLoad, fetchError, routeId, preferredId }) - if (idToLoad === null) { return } From 1afe262afc49b50ebc1b0dde5050df50fb87e099 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Thu, 27 Feb 2025 15:43:08 +0100 Subject: [PATCH 20/43] fix: state to keep track of whether there are any dashboards at all --- .../DashboardsBar/NavigationMenu/NavigationMenu.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 7099c6296..0478714a1 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -30,16 +30,13 @@ export const NavigationMenu = ({ close }) => { const dataEngine = useDataEngine() const dispatch = useDispatch() const filterText = useSelector(sGetDashboardsFilter) - const [hasDashboards, setHasDashboards] = useState(null) - + const [initialFetchComplete, setInitialFetchComplete] = useState(false) const [state, setState] = useState({ dashboards: [], nextPage: 1, searchTerm: filterText, }) - const [initialFetchComplete, setInitialFetchComplete] = useState(false) - const fetchDashboards = useCallback( async ({ dashboards, page, searchTerm }) => { const data = await dataEngine.query( @@ -59,8 +56,9 @@ export const NavigationMenu = ({ close }) => { : null, } - setInitialFetchComplete(true) - setHasDashboards(!!response.dashboards.length) + if (initialFetchComplete === false) { + setInitialFetchComplete(response.dashboards.length) + } setState((prevState) => ({ dashboards: @@ -71,7 +69,7 @@ export const NavigationMenu = ({ close }) => { searchTerm: prevState.searchTerm, })) }, - [dataEngine] + [dataEngine, initialFetchComplete] ) const onFilterChange = useCallback( @@ -112,7 +110,7 @@ export const NavigationMenu = ({ close }) => { }) }, []) - if (hasDashboards === false && !filterText) { + if (initialFetchComplete === 0) { return (

    {i18n.t('No dashboards available.')}

    From d5adb456badd396023deb88c3ba165c3414fd945 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 28 Feb 2025 10:48:15 +0100 Subject: [PATCH 21/43] fix: filtering and paging together now works --- src/actions/dashboardsFilter.js | 15 -- src/components/App.js | 2 - .../NavigationMenu/NavigationMenu.js | 151 ++++++++---------- .../__tests__/NavigationMenu.spec.js | 41 ++--- .../__tests__/dashboardsFilter.spec.js | 66 -------- src/reducers/dashboardsFilter.js | 26 --- src/reducers/index.js | 2 - 7 files changed, 76 insertions(+), 227 deletions(-) delete mode 100644 src/actions/dashboardsFilter.js delete mode 100644 src/reducers/__tests__/dashboardsFilter.spec.js delete mode 100644 src/reducers/dashboardsFilter.js diff --git a/src/actions/dashboardsFilter.js b/src/actions/dashboardsFilter.js deleted file mode 100644 index 276b96926..000000000 --- a/src/actions/dashboardsFilter.js +++ /dev/null @@ -1,15 +0,0 @@ -import { - SET_DASHBOARDS_FILTER, - CLEAR_DASHBOARDS_FILTER, -} from '../reducers/dashboardsFilter.js' - -// actions - -export const acSetDashboardsFilter = (value) => ({ - type: SET_DASHBOARDS_FILTER, - value, -}) - -export const acClearDashboardsFilter = () => ({ - type: CLEAR_DASHBOARDS_FILTER, -}) diff --git a/src/components/App.js b/src/components/App.js index 79f36bd10..efdc39dfb 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -5,7 +5,6 @@ import React, { useEffect } from 'react' import { connect } from 'react-redux' import { Redirect, HashRouter as Router, Route, Switch } from 'react-router-dom' import { acClearActiveModalDimension } from '../actions/activeModalDimension.js' -import { acClearDashboardsFilter } from '../actions/dashboardsFilter.js' import { acClearEditDashboard } from '../actions/editDashboard.js' import { acClearItemActiveTypes } from '../actions/itemActiveTypes.js' import { acClearItemFilters } from '../actions/itemFilters.js' @@ -102,7 +101,6 @@ const mapDispatchToProps = { setShowDescription: tSetShowDescription, resetState: () => (dispatch) => { dispatch(acSetSelected({})) - dispatch(acClearDashboardsFilter()) dispatch(acClearVisualizations()) dispatch(acClearEditDashboard()) dispatch(acClearPrintDashboard()) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 0478714a1..8bdce469a 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -4,9 +4,6 @@ import { Input, Menu } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React, { useCallback, useEffect, useRef, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { acSetDashboardsFilter } from '../../../actions/dashboardsFilter.js' -import { sGetDashboardsFilter } from '../../../reducers/dashboardsFilter.js' import { EndIntersectionDetector } from './EndIntersectionDetector.js' import { NavigationMenuItem } from './NavigationMenuItem.js' import styles from './styles/NavigationMenu.module.css' @@ -20,7 +17,7 @@ const dashboardsQuery = { order: 'favorite:desc,displayName:asc', filter: searchTerm ? `displayName:ilike:${searchTerm}` : undefined, paging: true, - pageSize: 40, + pageSize: 8, page, } }, @@ -28,23 +25,19 @@ const dashboardsQuery = { export const NavigationMenu = ({ close }) => { const dataEngine = useDataEngine() - const dispatch = useDispatch() - const filterText = useSelector(sGetDashboardsFilter) - const [initialFetchComplete, setInitialFetchComplete] = useState(false) - const [state, setState] = useState({ - dashboards: [], - nextPage: 1, - searchTerm: filterText, - }) + const [initialDashboardsCount, setInitialDashboardsCount] = useState(null) + const [dashboards, setDashboards] = useState([]) + const [filterText, setFilterText] = useState('') + const [page, setPage] = useState(1) - const fetchDashboards = useCallback( - async ({ dashboards, page, searchTerm }) => { + useEffect(() => { + const fetchDashboards = async () => { const data = await dataEngine.query( { dashboards: dashboardsQuery }, { variables: { page, - searchTerm, + searchTerm: filterText, }, } ) @@ -56,46 +49,34 @@ export const NavigationMenu = ({ close }) => { : null, } - if (initialFetchComplete === false) { - setInitialFetchComplete(response.dashboards.length) + if (initialDashboardsCount === null) { + setInitialDashboardsCount(response.dashboards.length) } - setState((prevState) => ({ - dashboards: - page > 1 - ? [...dashboards, ...response.dashboards] - : response.dashboards, - nextPage: response.nextPage, - searchTerm: prevState.searchTerm, - })) - }, - [dataEngine, initialFetchComplete] - ) + setDashboards((currentDashboards) => + page > 1 + ? [...currentDashboards, ...response.dashboards] + : response.dashboards + ) - const onFilterChange = useCallback( - ({ value }) => { - dispatch(acSetDashboardsFilter(value)) - fetchDashboards({ - dashboards: [], - page: 1, - searchTerm: value, - }) - }, - [dispatch, fetchDashboards] - ) + if (response.nextPage === null) { + setPage(null) + } + } + + if (page !== null) { + fetchDashboards() + } + }, [dataEngine, initialDashboardsCount, page, filterText]) + + const onFilterChange = useCallback(({ value }) => { + setFilterText(value) + setPage(1) + }, []) const onEndReached = useCallback(() => { - setState((prevState) => { - if (prevState.nextPage !== null) { - fetchDashboards({ - dashboards: prevState.dashboards, - page: prevState.nextPage, - searchTerm: prevState.searchTerm, - }) - } - return prevState - }) - }, [fetchDashboards]) + setPage((prevPage) => (prevPage !== null ? prevPage + 1 : prevPage)) + }, []) const scrollBoxRef = useRef(null) @@ -110,7 +91,7 @@ export const NavigationMenu = ({ close }) => { }) }, []) - if (initialFetchComplete === 0) { + if (initialDashboardsCount === 0) { return (

    {i18n.t('No dashboards available.')}

    @@ -131,39 +112,41 @@ export const NavigationMenu = ({ close }) => { initialFocus={true} />
    -
    - - {initialFetchComplete && state.dashboards.length === 0 ? ( -
  • - {i18n.t( - 'No dashboards found for "{{- filterText}}"', - { - filterText, - } - )} -
  • - ) : ( - <> - {state.dashboards.map( - ({ displayName, id, starred }) => ( - - ) - )} - - - )} -
    -
    + {initialDashboardsCount !== null && ( +
    + + {dashboards.length === 0 ? ( +
  • + {i18n.t( + 'No dashboards found for "{{- filterText}}"', + { + filterText, + } + )} +
  • + ) : ( + <> + {dashboards.map( + ({ displayName, id, starred }) => ( + + ) + )} + + + )} +
    +
    + )}
    ) } diff --git a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js index 8f80b382b..3e16dd0b1 100644 --- a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js +++ b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js @@ -1,19 +1,13 @@ import { render, waitFor, act } from '@testing-library/react' import { createMemoryHistory } from 'history' import React from 'react' -import { Provider } from 'react-redux' import { Router } from 'react-router-dom' -import { createStore } from 'redux' import { NavigationMenu } from '../NavigationMenu.js' jest.mock('@dhis2/app-runtime', () => ({ useDataEngine: jest.fn(), })) -jest.mock('../../../../actions/dashboardsFilter', () => ({ - acSetDashboardsFilter: jest.fn(), -})) - jest.mock('../EndIntersectionDetector.js', () => { const React = require('react') const PropTypes = require('prop-types') @@ -38,10 +32,6 @@ jest.mock('../NavigationMenuItem.js', () => { } }) -const baseState = { - dashboardsFilter: '', -} - const dashboards = { nghVC4wtyzi: { id: 'nghVC4wtyzi', @@ -70,15 +60,10 @@ const dashboards = { }, } -const createMockStore = (state) => - createStore(() => ({ ...baseState, ...state })) - describe('NavigationMenu', () => { - let store let dataEngine beforeEach(() => { - store = createMockStore({}) dataEngine = { query: jest.fn().mockResolvedValue({ dashboards: { @@ -95,11 +80,9 @@ describe('NavigationMenu', () => { it('requests the dashboards using the correct parameters', async () => { const { getAllByRole } = render( - - - {}} /> - - + + {}} /> + ) await waitFor(() => { @@ -113,7 +96,6 @@ describe('NavigationMenu', () => { }) it('renders a notification if no dashboards are available', async () => { - const mockStore = createMockStore({ dashboards: {} }) dataEngine.query.mockResolvedValueOnce({ dashboards: { dashboards: [], @@ -127,11 +109,9 @@ describe('NavigationMenu', () => { let getByText await act(async () => { const renderResult = render( - - - {}} /> - - + + {}} /> + ) getByText = renderResult.getByText }) @@ -144,7 +124,6 @@ describe('NavigationMenu', () => { it.skip('renders a placeholder list item if no dashboards meet the filter criteria', async () => { const filterStr = 'xxxxxxxxxxxxx' - const mockStore = createMockStore({ dashboardsFilter: filterStr }) // dataEngine.query.mockResolvedValueOnce({ // dashboards: { @@ -182,11 +161,9 @@ describe('NavigationMenu', () => { await act(async () => { const renderResult = render( - - - {}} /> - - + + {}} /> + ) getByText = renderResult.getByText getByPlaceholderText = renderResult.getByPlaceholderText diff --git a/src/reducers/__tests__/dashboardsFilter.spec.js b/src/reducers/__tests__/dashboardsFilter.spec.js deleted file mode 100644 index 5587fbf25..000000000 --- a/src/reducers/__tests__/dashboardsFilter.spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import reducer, { - DEFAULT_STATE_DASHBOARDS_FILTER, - SET_DASHBOARDS_FILTER, - CLEAR_DASHBOARDS_FILTER, - sGetDashboardsFilter, -} from '../dashboardsFilter.js' - -describe('dashboards filter reducer', () => { - it('returns the default state when action type unrecognized', () => { - const actualState = reducer(DEFAULT_STATE_DASHBOARDS_FILTER, {}) - - expect(actualState).toEqual(DEFAULT_STATE_DASHBOARDS_FILTER) - }) - - it('returns the default state when value is null', () => { - const actualState = reducer(DEFAULT_STATE_DASHBOARDS_FILTER, { - type: SET_DASHBOARDS_FILTER, - value: null, - }) - - expect(actualState).toEqual(DEFAULT_STATE_DASHBOARDS_FILTER) - }) - - it('returns the default state when value is undefined', () => { - const actualState = reducer(DEFAULT_STATE_DASHBOARDS_FILTER, { - type: SET_DASHBOARDS_FILTER, - }) - - expect(actualState).toEqual(DEFAULT_STATE_DASHBOARDS_FILTER) - }) - - it('sets the filter', () => { - const action = { - type: SET_DASHBOARDS_FILTER, - value: 'rainbowdash', - } - - const actualState = reducer(undefined, action) - - expect(actualState).toEqual('rainbowdash') - }) - - it('clears the filter', () => { - const action = { - type: CLEAR_DASHBOARDS_FILTER, - } - - const currentState = 'rainbow' - - const actualState = reducer(currentState, action) - - expect(actualState).toEqual(DEFAULT_STATE_DASHBOARDS_FILTER) - }) - - it('gets the current filter from state', () => { - const filterText = 'rainbow' - const action = { - type: SET_DASHBOARDS_FILTER, - value: filterText, - } - const dashboardsFilter = reducer(null, action) - const filterInState = sGetDashboardsFilter({ dashboardsFilter }) - - expect(filterInState).toEqual(filterText) - }) -}) diff --git a/src/reducers/dashboardsFilter.js b/src/reducers/dashboardsFilter.js deleted file mode 100644 index 637b32d00..000000000 --- a/src/reducers/dashboardsFilter.js +++ /dev/null @@ -1,26 +0,0 @@ -import { validateReducer } from '../modules/util.js' - -export const SET_DASHBOARDS_FILTER = 'SET_DASHBOARDS_FILTER' -export const CLEAR_DASHBOARDS_FILTER = 'CLEAR_DASHBOARDS_FILTER' - -export const DEFAULT_STATE_DASHBOARDS_FILTER = '' - -export default (state = DEFAULT_STATE_DASHBOARDS_FILTER, action) => { - switch (action.type) { - case SET_DASHBOARDS_FILTER: { - return validateReducer( - action.value, - DEFAULT_STATE_DASHBOARDS_FILTER - ) - } - case CLEAR_DASHBOARDS_FILTER: { - return DEFAULT_STATE_DASHBOARDS_FILTER - } - default: - return state - } -} - -// selectors - -export const sGetDashboardsFilter = (state) => state.dashboardsFilter diff --git a/src/reducers/index.js b/src/reducers/index.js index b79a131d4..9636fb0d7 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,6 +1,5 @@ import { combineReducers } from 'redux' import activeModalDimension from './activeModalDimension.js' -import dashboardsFilter from './dashboardsFilter.js' import dimensions from './dimensions.js' import editDashboard from './editDashboard.js' import iframePluginStatus from './iframePluginStatus.js' @@ -16,7 +15,6 @@ import visualizations from './visualizations.js' export default combineReducers({ selected, - dashboardsFilter, visualizations, messages, editDashboard, From 358d7f230db055f7d22e8a4a8e0833ce6737e386 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 28 Feb 2025 12:52:37 +0100 Subject: [PATCH 22/43] chore: temporarily change min height of nav menu --- .../NavigationMenu/styles/NavigationMenu.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css b/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css index da5626d33..073178a0d 100644 --- a/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css +++ b/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css @@ -17,7 +17,7 @@ * is 45px and the filter-wrap is 44px, so total height above * is 137px so 100vh - 152px ensures that 15px of whitespace * is visible below the menu. */ - max-block-size: min(1000px, calc(100vh - 152px)); + max-block-size: min(130px, calc(100vh - 152px)); overflow-y: auto; scroll-behavior: smooth; } From 8d2168503f48e62525d3807c850738d48d3e7eec Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Tue, 4 Mar 2025 16:13:52 +0100 Subject: [PATCH 23/43] fix: avoid duplicate requests when filtering --- .../NavigationMenu/NavigationMenu.js | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 8bdce469a..4b860944b 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -9,6 +9,8 @@ import { NavigationMenuItem } from './NavigationMenuItem.js' import styles from './styles/NavigationMenu.module.css' import itemStyles from './styles/NavigationMenuItem.module.css' +const hasDashboards = true + const dashboardsQuery = { resource: 'dashboards', params: ({ page, searchTerm }) => { @@ -25,7 +27,7 @@ const dashboardsQuery = { export const NavigationMenu = ({ close }) => { const dataEngine = useDataEngine() - const [initialDashboardsCount, setInitialDashboardsCount] = useState(null) + const [initialFetchComplete, setInitialFetchComplete] = useState(null) const [dashboards, setDashboards] = useState([]) const [filterText, setFilterText] = useState('') const [page, setPage] = useState(1) @@ -49,9 +51,7 @@ export const NavigationMenu = ({ close }) => { : null, } - if (initialDashboardsCount === null) { - setInitialDashboardsCount(response.dashboards.length) - } + setInitialFetchComplete(true) setDashboards((currentDashboards) => page > 1 @@ -67,19 +67,27 @@ export const NavigationMenu = ({ close }) => { if (page !== null) { fetchDashboards() } - }, [dataEngine, initialDashboardsCount, page, filterText]) + }, [dataEngine, page, filterText]) const onFilterChange = useCallback(({ value }) => { setFilterText(value) setPage(1) + // to prevent onEndReached from firing when the user is typing + scrollBoxRef.current?.scrollTo({ + top: 0, + left: 0, + behavior: 'smooth', + instant: false, + }) }, []) const onEndReached = useCallback(() => { - setPage((prevPage) => (prevPage !== null ? prevPage + 1 : prevPage)) + setPage((currPage) => (currPage !== null ? currPage + 1 : currPage)) }, []) const scrollBoxRef = useRef(null) + // scroll initially to the selected item useEffect(() => { scrollBoxRef.current ?.getElementsByClassName(itemStyles.selectedItem) @@ -91,7 +99,7 @@ export const NavigationMenu = ({ close }) => { }) }, []) - if (initialDashboardsCount === 0) { + if (!hasDashboards) { return (

    {i18n.t('No dashboards available.')}

    @@ -112,7 +120,7 @@ export const NavigationMenu = ({ close }) => { initialFocus={true} />
    - {initialDashboardsCount !== null && ( + {initialFetchComplete !== null && (
    {dashboards.length === 0 ? ( From 9714f0b9fcdfb82efdb28aff9a879d7a8b8e74f9 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Tue, 4 Mar 2025 17:00:43 +0100 Subject: [PATCH 24/43] fix: add hasDashboards prop --- src/components/DashboardsBar/DashboardsBar.js | 8 ++++++- .../NavigationMenu/NavigationMenu.js | 5 ++--- src/pages/start/LandingPage.js | 21 +++++++++++++++++-- src/pages/view/CacheableViewDashboard.js | 17 +++++++++++---- src/pages/view/ViewDashboard.js | 4 +++- 5 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/components/DashboardsBar/DashboardsBar.js b/src/components/DashboardsBar/DashboardsBar.js index 81abe8bb4..73d8e6d9c 100644 --- a/src/components/DashboardsBar/DashboardsBar.js +++ b/src/components/DashboardsBar/DashboardsBar.js @@ -1,12 +1,13 @@ import i18n from '@dhis2/d2-i18n' import { Button, IconAdd16, DropdownButton } from '@dhis2/ui' +import PropTypes from 'prop-types' import React, { useState } from 'react' import { useHistory } from 'react-router-dom' import InformationBlock from './InformationBlock/InformationBlock.js' import { IconNavigation, NavigationMenu } from './NavigationMenu/index.js' import styles from './styles/DashboardsBar.module.css' -export const DashboardsBar = () => { +export const DashboardsBar = ({ hasDashboards }) => { const history = useHistory() const [navigationMenuOpen, setNavigationMenuOpen] = useState(false) @@ -31,6 +32,7 @@ export const DashboardsBar = () => { component={ setNavigationMenuOpen(false)} + hasDashboards={hasDashboards} /> } > @@ -43,3 +45,7 @@ export const DashboardsBar = () => {
    ) } + +DashboardsBar.propTypes = { + hasDashboards: PropTypes.bool, +} diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 4b860944b..d345b53a6 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -9,8 +9,6 @@ import { NavigationMenuItem } from './NavigationMenuItem.js' import styles from './styles/NavigationMenu.module.css' import itemStyles from './styles/NavigationMenuItem.module.css' -const hasDashboards = true - const dashboardsQuery = { resource: 'dashboards', params: ({ page, searchTerm }) => { @@ -25,7 +23,7 @@ const dashboardsQuery = { }, } -export const NavigationMenu = ({ close }) => { +export const NavigationMenu = ({ close, hasDashboards }) => { const dataEngine = useDataEngine() const [initialFetchComplete, setInitialFetchComplete] = useState(null) const [dashboards, setDashboards] = useState([]) @@ -161,4 +159,5 @@ export const NavigationMenu = ({ close }) => { NavigationMenu.propTypes = { close: PropTypes.func.isRequired, + hasDashboards: PropTypes.bool.isRequired, } diff --git a/src/pages/start/LandingPage.js b/src/pages/start/LandingPage.js index 4b238f41c..49ebefbac 100644 --- a/src/pages/start/LandingPage.js +++ b/src/pages/start/LandingPage.js @@ -1,16 +1,33 @@ +import { useDataQuery } from '@dhis2/app-runtime' import PropTypes from 'prop-types' import React, { useEffect } from 'react' import DashboardsBar from '../../components/DashboardsBar/index.js' import StartScreen from './StartScreen.js' +const firstDashboardQuery = { + dashboards: { + resource: 'dashboards', + params: { + fields: 'id,favorite,displayName', + order: 'favorite:desc,displayName:asc', + paging: true, + pageSize: 1, + }, + }, +} + const LandingPage = ({ username, onMount }) => { + const { data } = useDataQuery(firstDashboardQuery) + useEffect(() => { onMount() - }, []) + }, [onMount]) return ( <> - + ) diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 78781ac12..6cfe1b57e 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -44,6 +44,7 @@ const CacheableViewDashboard = ({ match }) => { const dispatch = useDispatch() const [idToLoad, setIdToLoad] = useState(null) const [fetchError, setFetchError] = useState(null) + const [hasDashboards, setHasDashboards] = useState(true) const selectedId = useSelector(sGetSelectedId) const preferredId = getPreferredDashboardId(currentUser.username) || null // match comes from react-router-dom @@ -58,12 +59,14 @@ const CacheableViewDashboard = ({ match }) => { useEffect(() => { const fetchIdToLoad = async () => { try { + // no dashboard id provided so fetch the first starred/alphabetical dashboard if (!routeId && !preferredId) { const { dashboards } = await engine.query( firstDashboardQuery ) if (dashboards.dashboards.length === 0) { setFetchError(NO_DASHBOARDS_FOUND) + setHasDashboards(false) return } const firstDashboardId = dashboards?.dashboards[0]?.id @@ -71,6 +74,7 @@ const CacheableViewDashboard = ({ match }) => { return } + // get the dashboard by id if (routeId) { const { dashboard } = await engine.query( requestedDashboardQuery, @@ -82,6 +86,7 @@ const CacheableViewDashboard = ({ match }) => { return } + // get the preferred dashboard const { dashboard } = await engine.query( requestedDashboardQuery, { @@ -94,17 +99,20 @@ const CacheableViewDashboard = ({ match }) => { if (routeId) { setIdToLoad(null) setFetchError(REQUESTED_DASHBOARD_NOT_FOUND) - return } const { dashboards } = await engine.query(firstDashboardQuery) if (dashboards.dashboards.length === 0) { - setFetchError(NO_DASHBOARDS_FOUND) + setHasDashboards(false) + + if (!routeId) { + setFetchError(NO_DASHBOARDS_FOUND) + } setIdToLoad(null) return } - const firstDashboardId = dashboards?.dashboards[0]?.id + const firstDashboardId = dashboards.dashboards[0].id setIdToLoad(firstDashboardId) } } @@ -116,7 +124,7 @@ const CacheableViewDashboard = ({ match }) => { if (fetchError) { return ( <> - + { key={cacheSectionId} requestedId={idToLoad} username={currentUser.username} + hasDashboards={hasDashboards} /> ) diff --git a/src/pages/view/ViewDashboard.js b/src/pages/view/ViewDashboard.js index 4bc93af40..837e9701a 100644 --- a/src/pages/view/ViewDashboard.js +++ b/src/pages/view/ViewDashboard.js @@ -29,6 +29,7 @@ const ViewDashboard = ({ clearEditDashboard, clearPrintDashboard, fetchDashboard, + hasDashboards, passiveViewRegistered, registerPassiveView, requestedId, @@ -121,7 +122,7 @@ const ViewDashboard = ({ className={cx(classes.container, 'dashboard-scroll-container')} data-test="outer-scroll-container" > - + Date: Tue, 4 Mar 2025 17:21:53 +0100 Subject: [PATCH 25/43] feat: debounce filtering to 300ms --- .../NavigationMenu/NavigationMenu.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index d345b53a6..049e338bd 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -4,6 +4,7 @@ import { Input, Menu } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React, { useCallback, useEffect, useRef, useState } from 'react' +import useDebounce from '../../../modules/useDebounce.js' import { EndIntersectionDetector } from './EndIntersectionDetector.js' import { NavigationMenuItem } from './NavigationMenuItem.js' import styles from './styles/NavigationMenu.module.css' @@ -29,6 +30,8 @@ export const NavigationMenu = ({ close, hasDashboards }) => { const [dashboards, setDashboards] = useState([]) const [filterText, setFilterText] = useState('') const [page, setPage] = useState(1) + const debouncedFilterText = useDebounce(filterText, 300) + const debouncedPage = useDebounce(page, 500) useEffect(() => { const fetchDashboards = async () => { @@ -36,8 +39,8 @@ export const NavigationMenu = ({ close, hasDashboards }) => { { dashboards: dashboardsQuery }, { variables: { - page, - searchTerm: filterText, + page: debouncedPage, + searchTerm: debouncedFilterText, }, } ) @@ -52,7 +55,7 @@ export const NavigationMenu = ({ close, hasDashboards }) => { setInitialFetchComplete(true) setDashboards((currentDashboards) => - page > 1 + debouncedPage > 1 ? [...currentDashboards, ...response.dashboards] : response.dashboards ) @@ -62,15 +65,16 @@ export const NavigationMenu = ({ close, hasDashboards }) => { } } - if (page !== null) { + if (debouncedPage !== null) { fetchDashboards() } - }, [dataEngine, page, filterText]) + }, [dataEngine, debouncedPage, debouncedFilterText]) const onFilterChange = useCallback(({ value }) => { - setFilterText(value) setPage(1) - // to prevent onEndReached from firing when the user is typing + setFilterText(value) + + // prevent onEndReached from firing when the user changing filter text scrollBoxRef.current?.scrollTo({ top: 0, left: 0, From 08d7b871f4cd266cd3c470362ec5781a645f58c8 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Tue, 4 Mar 2025 17:29:38 +0100 Subject: [PATCH 26/43] chore: consolidate filterText and page to single state var --- .../NavigationMenu/NavigationMenu.js | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 049e338bd..783b88cd0 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -12,11 +12,11 @@ import itemStyles from './styles/NavigationMenuItem.module.css' const dashboardsQuery = { resource: 'dashboards', - params: ({ page, searchTerm }) => { + params: ({ page, filterText }) => { return { fields: 'id,displayName,favorite~rename(starred)', order: 'favorite:desc,displayName:asc', - filter: searchTerm ? `displayName:ilike:${searchTerm}` : undefined, + filter: filterText ? `displayName:ilike:${filterText}` : undefined, paging: true, pageSize: 8, page, @@ -28,19 +28,21 @@ export const NavigationMenu = ({ close, hasDashboards }) => { const dataEngine = useDataEngine() const [initialFetchComplete, setInitialFetchComplete] = useState(null) const [dashboards, setDashboards] = useState([]) - const [filterText, setFilterText] = useState('') - const [page, setPage] = useState(1) - const debouncedFilterText = useDebounce(filterText, 300) - const debouncedPage = useDebounce(page, 500) + const [requestParams, setRequestParams] = useState({ + page: 1, + filterText: '', + }) + const debouncedRequestParams = useDebounce(requestParams, 300) useEffect(() => { const fetchDashboards = async () => { + const { page, filterText } = debouncedRequestParams const data = await dataEngine.query( { dashboards: dashboardsQuery }, { variables: { - page: debouncedPage, - searchTerm: debouncedFilterText, + page, + filterText, }, } ) @@ -55,24 +57,23 @@ export const NavigationMenu = ({ close, hasDashboards }) => { setInitialFetchComplete(true) setDashboards((currentDashboards) => - debouncedPage > 1 + page > 1 ? [...currentDashboards, ...response.dashboards] : response.dashboards ) if (response.nextPage === null) { - setPage(null) + setRequestParams({ page: null, filterText }) } } - if (debouncedPage !== null) { + if (debouncedRequestParams.page !== null) { fetchDashboards() } - }, [dataEngine, debouncedPage, debouncedFilterText]) + }, [dataEngine, debouncedRequestParams]) const onFilterChange = useCallback(({ value }) => { - setPage(1) - setFilterText(value) + setRequestParams({ page: 1, filterText: value }) // prevent onEndReached from firing when the user changing filter text scrollBoxRef.current?.scrollTo({ @@ -84,7 +85,13 @@ export const NavigationMenu = ({ close, hasDashboards }) => { }, []) const onEndReached = useCallback(() => { - setPage((currPage) => (currPage !== null ? currPage + 1 : currPage)) + setRequestParams((currParams) => ({ + ...currParams, + page: + currParams.page !== null + ? currParams.page + 1 + : currParams.page, + })) }, []) const scrollBoxRef = useRef(null) @@ -117,7 +124,7 @@ export const NavigationMenu = ({ close, hasDashboards }) => { dense type="search" placeholder={i18n.t('Search for a dashboard')} - value={filterText} + value={requestParams.filterText} onChange={onFilterChange} initialFocus={true} /> @@ -130,7 +137,7 @@ export const NavigationMenu = ({ close, hasDashboards }) => { {i18n.t( 'No dashboards found for "{{- filterText}}"', { - filterText, + filterText: requestParams.filterText, } )} From 9500ca4da8bdb19c7cf64dee7c489268bbfebe96 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Tue, 4 Mar 2025 17:54:28 +0100 Subject: [PATCH 27/43] chore: a little cleanup --- src/components/DashboardsBar/DashboardsBar.js | 8 ++------ src/pages/view/CacheableViewDashboard.js | 1 + 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/components/DashboardsBar/DashboardsBar.js b/src/components/DashboardsBar/DashboardsBar.js index 73d8e6d9c..538820be8 100644 --- a/src/components/DashboardsBar/DashboardsBar.js +++ b/src/components/DashboardsBar/DashboardsBar.js @@ -7,7 +7,7 @@ import InformationBlock from './InformationBlock/InformationBlock.js' import { IconNavigation, NavigationMenu } from './NavigationMenu/index.js' import styles from './styles/DashboardsBar.module.css' -export const DashboardsBar = ({ hasDashboards }) => { +export const DashboardsBar = (props) => { const history = useHistory() const [navigationMenuOpen, setNavigationMenuOpen] = useState(false) @@ -32,7 +32,7 @@ export const DashboardsBar = ({ hasDashboards }) => { component={ setNavigationMenuOpen(false)} - hasDashboards={hasDashboards} + {...props} /> } > @@ -45,7 +45,3 @@ export const DashboardsBar = ({ hasDashboards }) => {
    ) } - -DashboardsBar.propTypes = { - hasDashboards: PropTypes.bool, -} diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 6cfe1b57e..389ff64d4 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -69,6 +69,7 @@ const CacheableViewDashboard = ({ match }) => { setHasDashboards(false) return } + const firstDashboardId = dashboards?.dashboards[0]?.id setIdToLoad(firstDashboardId) return From 60777d7a4409e8ab1044485d14727b2cb585cc97 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Tue, 4 Mar 2025 17:58:52 +0100 Subject: [PATCH 28/43] chore: fix tests --- .../NavigationMenu/__tests__/NavigationMenu.spec.js | 6 +++--- src/components/__tests__/App.spec.js | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js index 3e16dd0b1..3ad499dbb 100644 --- a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js +++ b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js @@ -81,14 +81,14 @@ describe('NavigationMenu', () => { it('requests the dashboards using the correct parameters', async () => { const { getAllByRole } = render( - {}} /> + {}} hasDashboards={true} /> ) await waitFor(() => { expect(dataEngine.query).toHaveBeenCalledWith( { dashboards: expect.any(Object) }, - { variables: { page: 1, searchTerm: '' } } + { variables: { page: 1, filterText: '' } } ) }) @@ -110,7 +110,7 @@ describe('NavigationMenu', () => { await act(async () => { const renderResult = render( - {}} /> + {}} hasDashboards={false} /> ) getByText = renderResult.getByText diff --git a/src/components/__tests__/App.spec.js b/src/components/__tests__/App.spec.js index 9ced9970f..ae4456f3e 100644 --- a/src/components/__tests__/App.spec.js +++ b/src/components/__tests__/App.spec.js @@ -21,6 +21,7 @@ jest.mock('@dhis2/app-runtime', () => ({ isDisconnected: false, })), useDataEngine: jest.fn(() => ({ query: Function.prototype })), + useDataQuery: jest.fn(() => ({ query: Function.prototype })), useCacheableSection: jest.fn, })) From 67b02f4a9b3f9a73a0e9b43033fa2d1e21b938f6 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 5 Mar 2025 10:43:21 +0100 Subject: [PATCH 29/43] test: moving and changing cypress tests --- cypress/e2e/view_dashboard.cy.js | 51 +++++++++++++++++++ .../common/type_invalid_id_in_url.js | 5 -- cypress/e2e_cucumber/view_dashboard.feature | 6 --- cypress/e2e_cucumber/view_errors.feature | 12 ----- .../e2e_cucumber/view_errors/no_dashboards.js | 13 ----- 5 files changed, 51 insertions(+), 36 deletions(-) create mode 100644 cypress/e2e/view_dashboard.cy.js delete mode 100644 cypress/e2e_cucumber/common/type_invalid_id_in_url.js delete mode 100644 cypress/e2e_cucumber/view_errors/no_dashboards.js diff --git a/cypress/e2e/view_dashboard.cy.js b/cypress/e2e/view_dashboard.cy.js new file mode 100644 index 000000000..f6040ecf0 --- /dev/null +++ b/cypress/e2e/view_dashboard.cy.js @@ -0,0 +1,51 @@ +import { dashboards } from '../assets/backends/index.js' +// import { gridItemSel, chartSel } from '../elements/dashboardItem.js' +import { getNavigationMenuItem } from '../elements/navigationMenu.js' +import { dashboardTitleSel, newButtonSel } from '../elements/viewDashboard.js' + + +describe('view dashboard', () => { + it('there are no dashboards', () => { + cy.intercept('**/dashboards?*', { body: { dashboards: [] } }) + cy.visit('/') + + cy.contains('No dashboards found').should('be.visible') + cy.get(newButtonSel).should('be.visible') + + // check the Navigation Menu + }) + + it('dashboard not found', () => { + cy.visit('#/invalid') + + cy.contains('Requested dashboard not found').should('be.visible') + + // When I open the "Delivery" dashboard + // Then the "Delivery" dashboard displays in view mode + }) + + // Scenario: I switch between dashboards + // Given I open the "Delivery" dashboard + // When I open the "Immunization" dashboard + // Then the "Immunization" dashboard displays in view mode + it.only('switch between dashboards', () => { + const title = 'Delivery' + // open the Delivery dashboard + cy.visit('/') + getNavigationMenuItem(title).click() + + cy.location().should((loc) => { + expect(loc.hash).to.equal(dashboards[title].route) + }) + + cy.get(dashboardTitleSel).should('be.visible').and('contain', title) + // cy.get(`${gridItemSel}.VISUALIZATION`) + // .first() + // .getIframeBody() + // .find(chartSel, EXTENDED_TIMEOUT) + // .as('vis') + // cy.get('@vis').should('exist') + + }) + +}) diff --git a/cypress/e2e_cucumber/common/type_invalid_id_in_url.js b/cypress/e2e_cucumber/common/type_invalid_id_in_url.js deleted file mode 100644 index 4df8e6cbf..000000000 --- a/cypress/e2e_cucumber/common/type_invalid_id_in_url.js +++ /dev/null @@ -1,5 +0,0 @@ -import { Given } from '@badeball/cypress-cucumber-preprocessor' - -Given('I type an invalid dashboard id in the browser url', () => { - cy.visit('#/invalid') -}) diff --git a/cypress/e2e_cucumber/view_dashboard.feature b/cypress/e2e_cucumber/view_dashboard.feature index d72cc9abf..6c9e65811 100644 --- a/cypress/e2e_cucumber/view_dashboard.feature +++ b/cypress/e2e_cucumber/view_dashboard.feature @@ -1,11 +1,5 @@ Feature: Viewing dashboards - @nonmutating - Scenario: I switch between dashboards - Given I open the "Delivery" dashboard - When I open the "Immunization" dashboard - Then the "Immunization" dashboard displays in view mode - @nonmutating Scenario: I search for a dashboard Given I open the "Antenatal Care" dashboard diff --git a/cypress/e2e_cucumber/view_errors.feature b/cypress/e2e_cucumber/view_errors.feature index e1fb5a87c..992d360ba 100644 --- a/cypress/e2e_cucumber/view_errors.feature +++ b/cypress/e2e_cucumber/view_errors.feature @@ -1,17 +1,5 @@ Feature: Errors while in view mode - @nonmutating - Scenario: There are no dashboards - Given I open an app with no dashboards - Then a message displays informing that there are no dashboards - - @nonmutating - Scenario: I navigate to a dashboard that doesn't exist or I don't have access to - Given I type an invalid dashboard id in the browser url - Then a message displays informing that the dashboard is not found - When I open the "Delivery" dashboard - Then the "Delivery" dashboard displays in view mode - @nonmutating Scenario: I navigate to edit dashboard that doesn't exist Given I type an invalid edit dashboard id in the browser url diff --git a/cypress/e2e_cucumber/view_errors/no_dashboards.js b/cypress/e2e_cucumber/view_errors/no_dashboards.js deleted file mode 100644 index 7257f355e..000000000 --- a/cypress/e2e_cucumber/view_errors/no_dashboards.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Given, Then } from '@badeball/cypress-cucumber-preprocessor' -import { newButtonSel } from '../../elements/viewDashboard.js' - -// Scenario: There are no dashboards -Given('I open an app with no dashboards', () => { - cy.intercept('**/dashboards?*', { body: { dashboards: [] } }) - cy.visit('/') -}) - -Then('a message displays informing that there are no dashboards', () => { - cy.contains('No dashboards found').should('be.visible') - cy.get(newButtonSel).should('be.visible') -}) From a9ae078625e46bd028f444983d8b6db2014c1996 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 5 Mar 2025 11:42:01 +0100 Subject: [PATCH 30/43] fix: prevent temporary flash of the offline message when switching dashboards --- src/pages/view/ViewDashboard.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/view/ViewDashboard.js b/src/pages/view/ViewDashboard.js index 837e9701a..34005fc09 100644 --- a/src/pages/view/ViewDashboard.js +++ b/src/pages/view/ViewDashboard.js @@ -33,6 +33,7 @@ const ViewDashboard = ({ passiveViewRegistered, registerPassiveView, requestedId, + currentId, setSelectedAsOffline, username, }) => { @@ -46,6 +47,7 @@ const ViewDashboard = ({ const { show: showAlert, hide: hideAlert } = useAlert( ({ message }) => message ) + const loadDashboard = useCallback(async () => { setLoading(true) @@ -126,7 +128,7 @@ const ViewDashboard = ({ @@ -138,6 +140,7 @@ const ViewDashboard = ({ ViewDashboard.propTypes = { clearEditDashboard: PropTypes.func, clearPrintDashboard: PropTypes.func, + currentId: PropTypes.string, fetchDashboard: PropTypes.func, hasDashboards: PropTypes.bool, passiveViewRegistered: PropTypes.bool, From de4e1bc78d7a320638ca9585d861c0e080b19043 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 5 Mar 2025 13:44:35 +0100 Subject: [PATCH 31/43] fix: reset hasDashboards to true if there are dashboards and add more tests --- cypress/e2e/view_dashboard.cy.js | 54 ++++++++++--------- cypress/support/utils.js | 2 +- .../NavigationMenu/NavigationMenu.js | 5 +- src/pages/view/CacheableViewDashboard.js | 1 + 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/cypress/e2e/view_dashboard.cy.js b/cypress/e2e/view_dashboard.cy.js index f6040ecf0..99447b8b8 100644 --- a/cypress/e2e/view_dashboard.cy.js +++ b/cypress/e2e/view_dashboard.cy.js @@ -1,18 +1,31 @@ import { dashboards } from '../assets/backends/index.js' -// import { gridItemSel, chartSel } from '../elements/dashboardItem.js' -import { getNavigationMenuItem } from '../elements/navigationMenu.js' +import { + getNavigationMenuDropdown, + getNavigationMenuItem, + closeNavigationMenu, +} from '../elements/navigationMenu.js' import { dashboardTitleSel, newButtonSel } from '../elements/viewDashboard.js' +const assertDashboardDisplayed = (title) => { + cy.location().should((loc) => { + expect(loc.hash).to.equal(dashboards[title].route) + }) + cy.get(dashboardTitleSel).should('be.visible').and('contain', title) +} describe('view dashboard', () => { it('there are no dashboards', () => { cy.intercept('**/dashboards?*', { body: { dashboards: [] } }) cy.visit('/') + // check that main dashboard area shows the no dashboards message cy.contains('No dashboards found').should('be.visible') cy.get(newButtonSel).should('be.visible') - // check the Navigation Menu + // check that NavigationMenu shows the no dashboards message + getNavigationMenuDropdown().click() + cy.getByDataTest('navmenu-no-dashboards-message').should('be.visible') + closeNavigationMenu() }) it('dashboard not found', () => { @@ -20,32 +33,23 @@ describe('view dashboard', () => { cy.contains('Requested dashboard not found').should('be.visible') - // When I open the "Delivery" dashboard - // Then the "Delivery" dashboard displays in view mode + // Open the Delivery dashboard + const title = 'Delivery' + getNavigationMenuItem(title).click() + assertDashboardDisplayed(title) }) - // Scenario: I switch between dashboards - // Given I open the "Delivery" dashboard - // When I open the "Immunization" dashboard - // Then the "Immunization" dashboard displays in view mode - it.only('switch between dashboards', () => { - const title = 'Delivery' - // open the Delivery dashboard + it('switch between dashboards', () => { cy.visit('/') + + // open the Delivery dashboard + const title = 'Delivery' getNavigationMenuItem(title).click() - - cy.location().should((loc) => { - expect(loc.hash).to.equal(dashboards[title].route) - }) - - cy.get(dashboardTitleSel).should('be.visible').and('contain', title) - // cy.get(`${gridItemSel}.VISUALIZATION`) - // .first() - // .getIframeBody() - // .find(chartSel, EXTENDED_TIMEOUT) - // .as('vis') - // cy.get('@vis').should('exist') + assertDashboardDisplayed(title) + // open the Immunization dashboard + const newTitle = 'Immunization' + getNavigationMenuItem(newTitle).click() + assertDashboardDisplayed(newTitle) }) - }) diff --git a/cypress/support/utils.js b/cypress/support/utils.js index a794a43f4..0911bb807 100644 --- a/cypress/support/utils.js +++ b/cypress/support/utils.js @@ -1,4 +1,4 @@ -export const EXTENDED_TIMEOUT = { timeout: 45000 } +export const EXTENDED_TIMEOUT = { timeout: 15000 } export const getApiBaseUrl = () => { const baseUrl = Cypress.env('dhis2BaseUrl') || '' diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 783b88cd0..f9fb7343b 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -110,7 +110,10 @@ export const NavigationMenu = ({ close, hasDashboards }) => { if (!hasDashboards) { return ( -
    +

    {i18n.t('No dashboards available.')}

    {i18n.t('Create a new dashboard using the + button.')}

    diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 389ff64d4..796a301c0 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -114,6 +114,7 @@ const CacheableViewDashboard = ({ match }) => { return } const firstDashboardId = dashboards.dashboards[0].id + setHasDashboards(true) setIdToLoad(firstDashboardId) } } From 50cf754fde68fdf483e75e77144c5eb09f23724f Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 5 Mar 2025 14:10:01 +0100 Subject: [PATCH 32/43] test: convert more tests related to nav menu --- cypress/e2e/view_dashboard.cy.js | 74 +++++++++++++++++++ cypress/e2e_cucumber/view_dashboard.feature | 43 ----------- .../dashboard_items_without_shape.js | 20 ----- .../NavigationMenu/NavigationMenu.js | 5 +- 4 files changed, 78 insertions(+), 64 deletions(-) delete mode 100644 cypress/e2e_cucumber/view_dashboard/dashboard_items_without_shape.js diff --git a/cypress/e2e/view_dashboard.cy.js b/cypress/e2e/view_dashboard.cy.js index 99447b8b8..e4b65f80a 100644 --- a/cypress/e2e/view_dashboard.cy.js +++ b/cypress/e2e/view_dashboard.cy.js @@ -3,6 +3,7 @@ import { getNavigationMenuDropdown, getNavigationMenuItem, closeNavigationMenu, + getNavigationMenuFilter, } from '../elements/navigationMenu.js' import { dashboardTitleSel, newButtonSel } from '../elements/viewDashboard.js' @@ -52,4 +53,77 @@ describe('view dashboard', () => { getNavigationMenuItem(newTitle).click() assertDashboardDisplayed(newTitle) }) + + it('search for a dashboard', () => { + cy.visit('/') + + // open the Antenatal Care dashboard + const title = 'Antenatal Care' + getNavigationMenuItem(title).click() + assertDashboardDisplayed(title) + + // search for Immun + getNavigationMenuFilter().type('Immun') + getNavigationMenuItem('Immunization', true).should('be.visible') + getNavigationMenuItem('Immunization data', true).should('be.visible') + + // open the Immunization dashboard + getNavigationMenuItem('Immunization', true).click() + assertDashboardDisplayed('Immunization') + }) + + it('search for a dashboard with nonmatching search text', () => { + cy.visit('/') + + // open the Antenatal Care dashboard + const title = 'Antenatal Care' + getNavigationMenuItem(title).click() + assertDashboardDisplayed(title) + + // search for Noexist + getNavigationMenuFilter().type('xyzpdq') + cy.getByDataTest('navmenu-no-items-found') + .should('be.visible') + .and('contain', 'No dashboards found for "xyzpdq"') + }) + + it('user preferred dashboard', () => { + cy.visit('/') + + // open the Antenatal Care dashboard + getNavigationMenuItem('Antenatal Care').click() + assertDashboardDisplayed('Antenatal Care') + + // open the Delivery dashboard + getNavigationMenuItem('Delivery').click() + assertDashboardDisplayed('Delivery') + + // open the root url which should display the Delivery dashboard + cy.visit('/') + cy.get(dashboardTitleSel) + .should('be.visible') + .and('contain', 'Delivery') + }) + + // Given I open the "Delivery" dashboard with shapes removed + // Then the "Delivery" dashboard displays in view mode + it('display dashboard with items lacking shape', () => { + const title = 'Delivery' + const regex = new RegExp(`dashboards/${dashboards[title].id}`, 'g') + cy.intercept(regex, (req) => { + req.reply((res) => { + res.body.dashboardItems.forEach((item) => { + delete item.x + delete item.y + delete item.w + delete item.h + }) + + res.send({ body: res.body }) + }) + }) + getNavigationMenuItem(title).click() + + assertDashboardDisplayed('Delivery') + }) }) diff --git a/cypress/e2e_cucumber/view_dashboard.feature b/cypress/e2e_cucumber/view_dashboard.feature index 6c9e65811..458429323 100644 --- a/cypress/e2e_cucumber/view_dashboard.feature +++ b/cypress/e2e_cucumber/view_dashboard.feature @@ -1,19 +1,5 @@ Feature: Viewing dashboards - @nonmutating - Scenario: I search for a dashboard - Given I open the "Antenatal Care" dashboard - When I search for dashboards containing "Immun" - Then Immunization and Immunization data dashboards are choices - When I click on the Immunization dashboard in the search results - Then the "Immunization" dashboard displays in view mode - - @nonmutating - Scenario: I search for a dashboard with nonmatching search text - Given I open the "Antenatal Care" dashboard - When I search for dashboards containing "Noexist" - Then no dashboards are choices - @nonmutating Scenario: I view the print layout preview and then print one-item-per-page preview Given I open the "Delivery" dashboard @@ -26,38 +12,9 @@ Feature: Viewing dashboards When I click to exit print preview Then the "Delivery" dashboard displays in view mode - @nonmutating - Scenario: I view a dashboard with items lacking shape - Given I open the "Delivery" dashboard with shapes removed - Then the "Delivery" dashboard displays in view mode - # @nonmutating # FIXME # Scenario: Maps with tracked entities show layer names in legend # Given I open the Cases Malaria dashboard # When I hover over the map legend button # Then the legend title shows the tracked entity name - - @nonmutating - Scenario: User's preferred dashboard is opened - Given I open the "Antenatal Care" dashboard - When I open the dashboard app with the root url - And I open the "Delivery" dashboard - And I open the dashboard app with the root url - Then the "Delivery" dashboard displays - - -# TODO: flaky test -# @mutating -# Scenario: I change the height of the control bar -# Given I open the "Delivery" dashboard -# When I drag to increase the height of the control bar -# Then the control bar height should be updated - -# TODO: flaky test -# @mutating -# Scenario: I change the height of an expanded control bar -# Given I open the "Delivery" dashboard -# When I toggle show more dashboards -# And I drag to decrease the height of the control bar -# Then the control bar height should be updated diff --git a/cypress/e2e_cucumber/view_dashboard/dashboard_items_without_shape.js b/cypress/e2e_cucumber/view_dashboard/dashboard_items_without_shape.js deleted file mode 100644 index 284b8c37a..000000000 --- a/cypress/e2e_cucumber/view_dashboard/dashboard_items_without_shape.js +++ /dev/null @@ -1,20 +0,0 @@ -import { Given } from '@badeball/cypress-cucumber-preprocessor' -import { dashboards } from '../../assets/backends/index.js' -import { getNavigationMenuItem } from '../../elements/navigationMenu.js' - -Given('I open the {string} dashboard with shapes removed', (title) => { - const regex = new RegExp(`dashboards/${dashboards[title].id}`, 'g') - cy.intercept(regex, (req) => { - req.reply((res) => { - res.body.dashboardItems.forEach((item) => { - delete item.x - delete item.y - delete item.w - delete item.h - }) - - res.send({ body: res.body }) - }) - }) - getNavigationMenuItem(title).click() -}) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index f9fb7343b..c07847566 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -136,7 +136,10 @@ export const NavigationMenu = ({ close, hasDashboards }) => {
    {dashboards.length === 0 ? ( -
  • +
  • {i18n.t( 'No dashboards found for "{{- filterText}}"', { From 501b702c4b8a8d893044235f8219f112b1f1a564 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 5 Mar 2025 14:10:45 +0100 Subject: [PATCH 33/43] chore: lint --- src/components/DashboardsBar/DashboardsBar.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/DashboardsBar/DashboardsBar.js b/src/components/DashboardsBar/DashboardsBar.js index 538820be8..f5c79f78c 100644 --- a/src/components/DashboardsBar/DashboardsBar.js +++ b/src/components/DashboardsBar/DashboardsBar.js @@ -1,6 +1,5 @@ import i18n from '@dhis2/d2-i18n' import { Button, IconAdd16, DropdownButton } from '@dhis2/ui' -import PropTypes from 'prop-types' import React, { useState } from 'react' import { useHistory } from 'react-router-dom' import InformationBlock from './InformationBlock/InformationBlock.js' From 799001c42c60118322b1235d89c90acead697b01 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 5 Mar 2025 15:13:56 +0100 Subject: [PATCH 34/43] fix: restore original nav menu size --- src/components/DashboardsBar/NavigationMenu/NavigationMenu.js | 2 +- .../NavigationMenu/styles/NavigationMenu.module.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index c07847566..5d04a9210 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -18,7 +18,7 @@ const dashboardsQuery = { order: 'favorite:desc,displayName:asc', filter: filterText ? `displayName:ilike:${filterText}` : undefined, paging: true, - pageSize: 8, + pageSize: 50, page, } }, diff --git a/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css b/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css index 073178a0d..da5626d33 100644 --- a/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css +++ b/src/components/DashboardsBar/NavigationMenu/styles/NavigationMenu.module.css @@ -17,7 +17,7 @@ * is 45px and the filter-wrap is 44px, so total height above * is 137px so 100vh - 152px ensures that 15px of whitespace * is visible below the menu. */ - max-block-size: min(130px, calc(100vh - 152px)); + max-block-size: min(1000px, calc(100vh - 152px)); overflow-y: auto; scroll-behavior: smooth; } From 576b4890d170a1a2c07c28e74b3df15cf730e189 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 5 Mar 2025 16:51:04 +0100 Subject: [PATCH 35/43] chore: share query object and dont bother removing preferred id --- cypress/e2e/view_dashboard.cy.js | 3 +-- src/api/fetchDashboards.js | 21 +++++++++++++++++++ src/modules/localStorage.js | 4 ---- src/pages/edit/ActionsBar.js | 2 -- src/pages/start/LandingPage.js | 13 +----------- src/pages/view/CacheableViewDashboard.js | 26 ++++-------------------- 6 files changed, 27 insertions(+), 42 deletions(-) create mode 100644 src/api/fetchDashboards.js diff --git a/cypress/e2e/view_dashboard.cy.js b/cypress/e2e/view_dashboard.cy.js index e4b65f80a..7141a138c 100644 --- a/cypress/e2e/view_dashboard.cy.js +++ b/cypress/e2e/view_dashboard.cy.js @@ -66,6 +66,7 @@ describe('view dashboard', () => { getNavigationMenuFilter().type('Immun') getNavigationMenuItem('Immunization', true).should('be.visible') getNavigationMenuItem('Immunization data', true).should('be.visible') + getNavigationMenuItem('Delivery', true).should('not.exist') // open the Immunization dashboard getNavigationMenuItem('Immunization', true).click() @@ -105,8 +106,6 @@ describe('view dashboard', () => { .and('contain', 'Delivery') }) - // Given I open the "Delivery" dashboard with shapes removed - // Then the "Delivery" dashboard displays in view mode it('display dashboard with items lacking shape', () => { const title = 'Delivery' const regex = new RegExp(`dashboards/${dashboards[title].id}`, 'g') diff --git a/src/api/fetchDashboards.js b/src/api/fetchDashboards.js new file mode 100644 index 000000000..56cdf7617 --- /dev/null +++ b/src/api/fetchDashboards.js @@ -0,0 +1,21 @@ +export const firstDashboardQuery = { + dashboards: { + resource: 'dashboards', + params: { + fields: 'id,favorite,displayName', + order: 'favorite:desc,displayName:asc', + paging: true, + pageSize: 1, + }, + }, +} + +export const requestedDashboardQuery = { + dashboard: { + resource: 'dashboards', + id: ({ id }) => id, + params: { + fields: ['id', 'displayName'], + }, + }, +} diff --git a/src/modules/localStorage.js b/src/modules/localStorage.js index 7c1cfe423..048a59109 100644 --- a/src/modules/localStorage.js +++ b/src/modules/localStorage.js @@ -5,10 +5,6 @@ export const storePreferredDashboardId = (username, dashboardId) => { localStorage.setItem(`dhis2.dashboard.current.${username}`, dashboardId) } -export const removePreferredDashboardId = (username) => { - localStorage.removeItem(`dhis2.dashboard.current.${username}`) -} - export const getPluginOverrides = () => (process.env.NODE_ENV !== 'production' && JSON.parse(localStorage.getItem('dhis2.dashboard.pluginOverrides'))) || diff --git a/src/pages/edit/ActionsBar.js b/src/pages/edit/ActionsBar.js index 959b0e76b..13cf49acd 100644 --- a/src/pages/edit/ActionsBar.js +++ b/src/pages/edit/ActionsBar.js @@ -24,7 +24,6 @@ import { import { acClearPrintDashboard } from '../../actions/printDashboard.js' import { acClearSelected } from '../../actions/selected.js' import ConfirmActionDialog from '../../components/ConfirmActionDialog.js' -import { removePreferredDashboardId } from '../../modules/localStorage.js' import { sGetEditDashboardRoot, sGetIsPrintPreviewView, @@ -87,7 +86,6 @@ const EditBar = ({ dashboard, ...props }) => { }) .then(() => { props.clearSelected() - removePreferredDashboardId(currentUser.username) }) .then(() => setRedirectUrl('/')) .catch(deleteFailureAlert.show) diff --git a/src/pages/start/LandingPage.js b/src/pages/start/LandingPage.js index 49ebefbac..8b63863e3 100644 --- a/src/pages/start/LandingPage.js +++ b/src/pages/start/LandingPage.js @@ -1,21 +1,10 @@ import { useDataQuery } from '@dhis2/app-runtime' import PropTypes from 'prop-types' import React, { useEffect } from 'react' +import { firstDashboardQuery } from '../../api/fetchDashboards.js' import DashboardsBar from '../../components/DashboardsBar/index.js' import StartScreen from './StartScreen.js' -const firstDashboardQuery = { - dashboards: { - resource: 'dashboards', - params: { - fields: 'id,favorite,displayName', - order: 'favorite:desc,displayName:asc', - paging: true, - pageSize: 1, - }, - }, -} - const LandingPage = ({ username, onMount }) => { const { data } = useDataQuery(firstDashboardQuery) diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 796a301c0..0504f72b6 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -5,6 +5,10 @@ import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' import { useSelector, useDispatch } from 'react-redux' import { acClearSelected } from '../../actions/selected.js' +import { + firstDashboardQuery, + requestedDashboardQuery, +} from '../../api/fetchDashboards.js' import DashboardsBar from '../../components/DashboardsBar/index.js' import LoadingMask from '../../components/LoadingMask.js' import NoContentMessage from '../../components/NoContentMessage.js' @@ -13,28 +17,6 @@ import { getPreferredDashboardId } from '../../modules/localStorage.js' import { sGetSelectedId } from '../../reducers/selected.js' import ViewDashboard from './ViewDashboard.js' -const firstDashboardQuery = { - dashboards: { - resource: 'dashboards', - params: { - fields: 'id,favorite,displayName', - order: 'favorite:desc,displayName:asc', - paging: true, - pageSize: 1, - }, - }, -} - -const requestedDashboardQuery = { - dashboard: { - resource: 'dashboards', - id: ({ id }) => id, - params: { - fields: ['id', 'displayName'], - }, - }, -} - const NO_DASHBOARDS_FOUND = 'NO_DASHBOARDS_FOUND' const REQUESTED_DASHBOARD_NOT_FOUND = 'REQUESTED_DASHBOARD_NOT_FOUND' From eefbf3f6b45ab7dbf055d3987b28f59fff8b7655 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Wed, 5 Mar 2025 17:28:48 +0100 Subject: [PATCH 36/43] chore: lint --- src/pages/edit/ActionsBar.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/pages/edit/ActionsBar.js b/src/pages/edit/ActionsBar.js index 13cf49acd..976cb33b0 100644 --- a/src/pages/edit/ActionsBar.js +++ b/src/pages/edit/ActionsBar.js @@ -1,8 +1,4 @@ -import { - OfflineTooltip, - TranslationDialog, - useCachedDataQuery, -} from '@dhis2/analytics' +import { OfflineTooltip, TranslationDialog } from '@dhis2/analytics' import { useDhis2ConnectionStatus, useDataEngine, @@ -49,7 +45,6 @@ const deleteFailedMessage = i18n.t( const fieldsToTranslate = ['name', 'description'] const EditBar = ({ dashboard, ...props }) => { - const { currentUser } = useCachedDataQuery() const dataEngine = useDataEngine() const { isConnected: online } = useDhis2ConnectionStatus() const [translationDlgIsOpen, setTranslationDlgIsOpen] = useState(false) From 4cb5d2dfeed23f4112c763a7ff51054823d7ab42 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 7 Mar 2025 10:28:55 +0100 Subject: [PATCH 37/43] chore: clean up --- cypress/e2e/view_dashboard.cy.js | 2 +- .../NavigationMenu/NavigationMenu.js | 2 +- .../__tests__/NavigationMenu.spec.js | 65 ------------------- 3 files changed, 2 insertions(+), 67 deletions(-) diff --git a/cypress/e2e/view_dashboard.cy.js b/cypress/e2e/view_dashboard.cy.js index 7141a138c..7d6926c45 100644 --- a/cypress/e2e/view_dashboard.cy.js +++ b/cypress/e2e/view_dashboard.cy.js @@ -44,7 +44,7 @@ describe('view dashboard', () => { cy.visit('/') // open the Delivery dashboard - const title = 'Delivery' + const title = 'Cases Malaria' getNavigationMenuItem(title).click() assertDashboardDisplayed(title) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 5d04a9210..9d765aaa6 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -18,7 +18,7 @@ const dashboardsQuery = { order: 'favorite:desc,displayName:asc', filter: filterText ? `displayName:ilike:${filterText}` : undefined, paging: true, - pageSize: 50, + pageSize: 200, page, } }, diff --git a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js index 3ad499dbb..2014bd64d 100644 --- a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js +++ b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js @@ -96,16 +96,6 @@ describe('NavigationMenu', () => { }) it('renders a notification if no dashboards are available', async () => { - dataEngine.query.mockResolvedValueOnce({ - dashboards: { - dashboards: [], - pager: { - page: 1, - nextPage: null, - }, - }, - }) - let getByText await act(async () => { const renderResult = render( @@ -121,59 +111,4 @@ describe('NavigationMenu', () => { getByText('Create a new dashboard using the + button.') ).toBeVisible() }) - - it.skip('renders a placeholder list item if no dashboards meet the filter criteria', async () => { - const filterStr = 'xxxxxxxxxxxxx' - - // dataEngine.query.mockResolvedValueOnce({ - // dashboards: { - // dashboards: [], - // pager: { - // page: 1, - // nextPage: null, - // }, - // }, - // }) - dataEngine = { - query: jest - .fn() - .mockResolvedValueOnce({ - dashboards: { - dashboards: Object.values(dashboards), - pager: { - page: 1, - nextPage: null, - }, - }, - }) - .mockResolvedValueOnce({ - dashboards: { - dashboards: [], - pager: { - page: 1, - nextPage: null, - }, - }, - }), - } - - let getByText, getByPlaceholderText - - await act(async () => { - const renderResult = render( - - {}} /> - - ) - getByText = renderResult.getByText - getByPlaceholderText = renderResult.getByPlaceholderText - }) - - expect(getByPlaceholderText('Search for a dashboard')).toHaveValue( - filterStr - ) - expect( - getByText(`No dashboards found for "${filterStr}"`) - ).toBeVisible() - }) }) From 9db88c893032b5e8bf5b464d3d8bfd9e5836c575 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 7 Mar 2025 11:43:08 +0100 Subject: [PATCH 38/43] chore: small refactor of fetch dashboard code --- cypress/e2e/view_dashboard.cy.js | 10 ++++ src/api/fetchDashboards.js | 2 +- src/pages/view/CacheableViewDashboard.js | 70 +++++++++--------------- src/pages/view/ViewDashboard.js | 23 ++++++-- 4 files changed, 54 insertions(+), 51 deletions(-) diff --git a/cypress/e2e/view_dashboard.cy.js b/cypress/e2e/view_dashboard.cy.js index 7d6926c45..924f79228 100644 --- a/cypress/e2e/view_dashboard.cy.js +++ b/cypress/e2e/view_dashboard.cy.js @@ -40,6 +40,16 @@ describe('view dashboard', () => { assertDashboardDisplayed(title) }) + it('dashboard not found and no dashboards available', () => { + cy.intercept('**/dashboards?*', { body: { dashboards: [] } }) + cy.visit('#/invalid') + + // check that NavigationMenu shows the no dashboards message + getNavigationMenuDropdown().click() + cy.getByDataTest('navmenu-no-dashboards-message').should('be.visible') + closeNavigationMenu() + }) + it('switch between dashboards', () => { cy.visit('/') diff --git a/src/api/fetchDashboards.js b/src/api/fetchDashboards.js index 56cdf7617..1b2853d7c 100644 --- a/src/api/fetchDashboards.js +++ b/src/api/fetchDashboards.js @@ -2,7 +2,7 @@ export const firstDashboardQuery = { dashboards: { resource: 'dashboards', params: { - fields: 'id,favorite,displayName', + fields: 'id,displayName', order: 'favorite:desc,displayName:asc', paging: true, pageSize: 1, diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 0504f72b6..1979fb2df 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -25,81 +25,63 @@ const CacheableViewDashboard = ({ match }) => { const engine = useDataEngine() const dispatch = useDispatch() const [idToLoad, setIdToLoad] = useState(null) + const [dashboardName, setDashboardName] = useState(null) const [fetchError, setFetchError] = useState(null) - const [hasDashboards, setHasDashboards] = useState(true) - const selectedId = useSelector(sGetSelectedId) + const [hasDashboards, setHasDashboards] = useState(null) + const currentId = useSelector(sGetSelectedId) const preferredId = getPreferredDashboardId(currentUser.username) || null // match comes from react-router-dom const routeId = match?.params?.dashboardId || null useEffect(() => { - if (routeId === null && preferredId === null && selectedId !== null) { + if (routeId === null && preferredId === null && currentId !== null) { dispatch(acClearSelected()) } - }, [routeId, preferredId, selectedId, dispatch]) + }, [routeId, preferredId, currentId, dispatch]) useEffect(() => { const fetchIdToLoad = async () => { try { - // no dashboard id provided so fetch the first starred/alphabetical dashboard + // no dashboard id provided so fetch the first + // starred/alphabetical dashboard in the catch block if (!routeId && !preferredId) { - const { dashboards } = await engine.query( - firstDashboardQuery - ) - if (dashboards.dashboards.length === 0) { - setFetchError(NO_DASHBOARDS_FOUND) - setHasDashboards(false) - return - } - - const firstDashboardId = dashboards?.dashboards[0]?.id - setIdToLoad(firstDashboardId) - return - } - - // get the dashboard by id - if (routeId) { - const { dashboard } = await engine.query( - requestedDashboardQuery, - { - variables: { id: routeId }, - } - ) - setIdToLoad(dashboard.id) - return + throw new Error('No dashboard id provided') } - // get the preferred dashboard + // get the dashboard by id, throws an error if the dashboard is not found const { dashboard } = await engine.query( requestedDashboardQuery, { - variables: { id: preferredId }, + variables: { id: routeId || preferredId }, } ) + setDashboardName(dashboard.displayName) setIdToLoad(dashboard.id) - return } catch (error) { if (routeId) { + // show error msg since routeId was requested but wasn't found setIdToLoad(null) + setDashboardName(null) setFetchError(REQUESTED_DASHBOARD_NOT_FOUND) } - + // still need to know if there are any dashboards for the navigation menu const { dashboards } = await engine.query(firstDashboardQuery) - if (dashboards.dashboards.length === 0) { - setHasDashboards(false) + setHasDashboards(dashboards.dashboards.length > 0) - if (!routeId) { - setFetchError(NO_DASHBOARDS_FOUND) - } - setIdToLoad(null) - return + if (!routeId) { + setDashboardName( + dashboards.dashboards[0]?.displayName || null + ) + setIdToLoad(dashboards.dashboards[0]?.id || null) + setFetchError( + !dashboards.dashboards.length && NO_DASHBOARDS_FOUND + ) } - const firstDashboardId = dashboards.dashboards[0].id - setHasDashboards(true) - setIdToLoad(firstDashboardId) } } + + setIdToLoad(null) setFetchError(null) fetchIdToLoad() @@ -109,7 +91,6 @@ const CacheableViewDashboard = ({ match }) => { return ( <> - { requestedId={idToLoad} username={currentUser.username} hasDashboards={hasDashboards} + requestedDashboardName={dashboardName} /> ) diff --git a/src/pages/view/ViewDashboard.js b/src/pages/view/ViewDashboard.js index 34005fc09..ba3541cf0 100644 --- a/src/pages/view/ViewDashboard.js +++ b/src/pages/view/ViewDashboard.js @@ -32,6 +32,7 @@ const ViewDashboard = ({ hasDashboards, passiveViewRegistered, registerPassiveView, + requestedDashboardName, requestedId, currentId, setSelectedAsOffline, @@ -47,14 +48,17 @@ const ViewDashboard = ({ const { show: showAlert, hide: hideAlert } = useAlert( ({ message }) => message ) - const loadDashboard = useCallback(async () => { setLoading(true) - alertTimeoutRef.current = setTimeout( - () => showAlert({ message: i18n.t('Loading dashboard') }), - 500 - ) + alertTimeoutRef.current = setTimeout(() => { + const message = requestedDashboardName + ? i18n.t('Loading dashboard – {{name}}', { + name: requestedDashboardName, + }) + : i18n.t('Loading dashboard') + showAlert({ message }) + }, 500) try { await fetchDashboard(requestedId, username) @@ -66,7 +70,14 @@ const ViewDashboard = ({ setLoading(false) clearTimeout(alertTimeoutRef.current) } - }, [fetchDashboard, requestedId, setSelectedAsOffline, showAlert, username]) + }, [ + fetchDashboard, + requestedDashboardName, + requestedId, + setSelectedAsOffline, + showAlert, + username, + ]) useEffect(() => { if (!loading && !loaded && !loadFailed) { From e2c4e26fd34c93eb10303d587337d36ad0cb3918 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 7 Mar 2025 11:46:32 +0100 Subject: [PATCH 39/43] chore: lint --- src/pages/view/ViewDashboard.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/view/ViewDashboard.js b/src/pages/view/ViewDashboard.js index ba3541cf0..73f2b1957 100644 --- a/src/pages/view/ViewDashboard.js +++ b/src/pages/view/ViewDashboard.js @@ -156,6 +156,7 @@ ViewDashboard.propTypes = { hasDashboards: PropTypes.bool, passiveViewRegistered: PropTypes.bool, registerPassiveView: PropTypes.func, + requestedDashboardName: PropTypes.string, requestedId: PropTypes.string, setSelectedAsOffline: PropTypes.func, username: PropTypes.string, From 4b06fa12f3e3e62104a5bd6bfc080a35c0f97615 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 7 Mar 2025 15:28:04 +0100 Subject: [PATCH 40/43] fix: need to set hasDashboards --- i18n/en.pot | 7 +++++-- src/pages/view/CacheableViewDashboard.js | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index b622fd184..7445e9e3a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-03-05T13:32:39.554Z\n" -"PO-Revision-Date: 2025-03-05T13:32:39.569Z\n" +"POT-Creation-Date: 2025-03-07T14:21:33.030Z\n" +"PO-Revision-Date: 2025-03-07T14:21:33.031Z\n" msgid "Untitled dashboard" msgstr "Untitled dashboard" @@ -616,6 +616,9 @@ msgid_plural "{{count}} filters active" msgstr[0] "{{count}} filter active" msgstr[1] "{{count}} filters active" +msgid "Loading dashboard – {{name}}" +msgstr "Loading dashboard – {{name}}" + msgid "Loading dashboard" msgstr "Loading dashboard" diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 1979fb2df..7a00f3125 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -57,6 +57,7 @@ const CacheableViewDashboard = ({ match }) => { ) setDashboardName(dashboard.displayName) setIdToLoad(dashboard.id) + setHasDashboards(true) } catch (error) { if (routeId) { // show error msg since routeId was requested but wasn't found From 4a4b29ced2dd65c4431d49f94f0cf403fcf61fd2 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 7 Mar 2025 15:43:41 +0100 Subject: [PATCH 41/43] chore: make logic more logical :P --- src/pages/view/CacheableViewDashboard.js | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 7a00f3125..e4618358e 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -42,8 +42,7 @@ const CacheableViewDashboard = ({ match }) => { useEffect(() => { const fetchIdToLoad = async () => { try { - // no dashboard id provided so fetch the first - // starred/alphabetical dashboard in the catch block + // no id, so fetch the first starred/alphabetical dashboard in the catch block if (!routeId && !preferredId) { throw new Error('No dashboard id provided') } @@ -59,13 +58,6 @@ const CacheableViewDashboard = ({ match }) => { setIdToLoad(dashboard.id) setHasDashboards(true) } catch (error) { - if (routeId) { - // show error msg since routeId was requested but wasn't found - setIdToLoad(null) - setDashboardName(null) - setFetchError(REQUESTED_DASHBOARD_NOT_FOUND) - } - // still need to know if there are any dashboards for the navigation menu const { dashboards } = await engine.query(firstDashboardQuery) setHasDashboards(dashboards.dashboards.length > 0) @@ -78,11 +70,14 @@ const CacheableViewDashboard = ({ match }) => { setFetchError( !dashboards.dashboards.length && NO_DASHBOARDS_FOUND ) + } else { + setFetchError(REQUESTED_DASHBOARD_NOT_FOUND) } } } setIdToLoad(null) + setDashboardName(null) setFetchError(null) fetchIdToLoad() From 3b75b25f046828907f6bf27b901b355daf4a1e1d Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Mon, 10 Mar 2025 11:27:01 +0100 Subject: [PATCH 42/43] chore: look what is in cachedSections --- src/components/DashboardsBar/NavigationMenu/NavigationMenu.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 9d765aaa6..7d40986f8 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -1,4 +1,4 @@ -import { useDataEngine } from '@dhis2/app-runtime' +import { useDataEngine, useCachedSections } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { Input, Menu } from '@dhis2/ui' import cx from 'classnames' @@ -25,6 +25,7 @@ const dashboardsQuery = { } export const NavigationMenu = ({ close, hasDashboards }) => { + const { cachedSections } = useCachedSections() const dataEngine = useDataEngine() const [initialFetchComplete, setInitialFetchComplete] = useState(null) const [dashboards, setDashboards] = useState([]) @@ -33,6 +34,7 @@ export const NavigationMenu = ({ close, hasDashboards }) => { filterText: '', }) const debouncedRequestParams = useDebounce(requestParams, 300) + console.log('jj cachedSections', cachedSections) useEffect(() => { const fetchDashboards = async () => { From 468ae0b6fcc4e43570908d9ff8922347271a110f Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 21 Mar 2025 15:25:32 +0100 Subject: [PATCH 43/43] fix: dont fetch dashboards --- src/api/fetchDashboards.js | 14 +++++ .../CreateSupersetDashboardModal.js | 6 +-- .../InformationBlock/InformationBlock.js | 51 +++++-------------- .../NavigationMenu/NavigationMenu.js | 32 +++++++++--- src/modules/getCacheableSectionId.js | 16 +++++- src/modules/useCacheableSection.js | 2 +- src/modules/useSupersetDashboardMutation.js | 2 - src/pages/view/CacheableViewDashboard.js | 44 ++++++++++++++-- src/reducers/index.js | 2 + 9 files changed, 113 insertions(+), 56 deletions(-) diff --git a/src/api/fetchDashboards.js b/src/api/fetchDashboards.js index 1b2853d7c..f8480b929 100644 --- a/src/api/fetchDashboards.js +++ b/src/api/fetchDashboards.js @@ -19,3 +19,17 @@ export const requestedDashboardQuery = { }, }, } + +export const dashboardsByIdsQuery = { + dashboards: { + resource: 'dashboards', + params: ({ ids }) => { + return { + fields: 'id,displayName,favorite~rename(starred)', + order: 'favorite:desc,displayName:asc', + filter: ids ? `id:in:[${ids.join(',')}]` : undefined, + paging: false, + } + }, + }, +} diff --git a/src/components/DashboardsBar/ConfigureSupersetDashboard/CreateSupersetDashboardModal.js b/src/components/DashboardsBar/ConfigureSupersetDashboard/CreateSupersetDashboardModal.js index 823d55389..07ca51e07 100644 --- a/src/components/DashboardsBar/ConfigureSupersetDashboard/CreateSupersetDashboardModal.js +++ b/src/components/DashboardsBar/ConfigureSupersetDashboard/CreateSupersetDashboardModal.js @@ -10,9 +10,7 @@ import { } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useCallback, useState } from 'react' -import { useDispatch } from 'react-redux' import { useHistory } from 'react-router-dom' -import { tFetchDashboards } from '../../../actions/dashboards.js' import { parseSupersetDashboardFieldValues } from '../../../modules/parseSupersetDashboardFieldValues.js' import { useSupersetDashboardFieldsState } from '../../../modules/useSupersetDashboardFieldsState.js' import styles from './styles/SupersetDashboardModal.module.css' @@ -29,7 +27,6 @@ export const CreateSupersetDashboardModal = ({ backToChooseDashboardModal, closeModal, }) => { - const dispatch = useDispatch() const history = useHistory() const [loading, setLoading] = useState(false) const [postDashboard, { error }] = useDataMutation(postDashboardQuery, { @@ -49,12 +46,11 @@ export const CreateSupersetDashboardModal = ({ event.preventDefault() setLoading(true) const { response } = await postDashboard({ values }) - await dispatch(tFetchDashboards()) setLoading(false) closeModal() history.push(`/${response.uid}`) }, - [values, postDashboard, closeModal, dispatch, history] + [values, postDashboard, closeModal, history] ) return ( diff --git a/src/components/DashboardsBar/InformationBlock/InformationBlock.js b/src/components/DashboardsBar/InformationBlock/InformationBlock.js index 9ee432099..f1548cbad 100644 --- a/src/components/DashboardsBar/InformationBlock/InformationBlock.js +++ b/src/components/DashboardsBar/InformationBlock/InformationBlock.js @@ -2,28 +2,23 @@ import { Tag } from '@dhis2-ui/tag' import { useAlert, useDataEngine } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { Tooltip } from '@dhis2/ui' -import PropTypes from 'prop-types' import React, { useCallback } from 'react' -import { connect } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' +import { acSetOfflineDashboardStarred } from '../../../actions/offlineDashboards.js' import { acSetSelectedStarred } from '../../../actions/selected.js' -import { - sGetSelected, - sGetSelectedStarred, -} from '../../../reducers/selected.js' +import { sGetSelected } from '../../../reducers/selected.js' import ActionsBar from './ActionsBar.js' import { apiStarDashboard } from './apiStarDashboard.js' import LastUpdatedTag from './LastUpdatedTag.js' import StarDashboardButton from './StarDashboardButton.js' import classes from './styles/InformationBlock.module.css' -const InformationBlock = ({ - id, - isEmbeddedDashboard, - displayName, - starred, - setDashboardStarred, -}) => { +const InformationBlock = () => { const dataEngine = useDataEngine() + const dispatch = useDispatch() + const { id, displayName, starred, embedded } = useSelector(sGetSelected) + const isEmbeddedDashboard = !!embedded + const { show: showAlert } = useAlert( ({ msg }) => msg, ({ isCritical }) => @@ -33,7 +28,10 @@ const InformationBlock = ({ () => apiStarDashboard(dataEngine, id, !starred) .then(() => { - setDashboardStarred(!starred) + dispatch(acSetSelectedStarred(!starred)) + dispatch( + acSetOfflineDashboardStarred({ id, starred: !starred }) + ) }) .catch(() => { const msg = starred @@ -41,7 +39,7 @@ const InformationBlock = ({ : i18n.t('Failed to star the dashboard') showAlert({ msg, isCritical: false }) }), - [dataEngine, id, setDashboardStarred, showAlert, starred] + [dataEngine, id, showAlert, starred, dispatch] ) if (!id) { @@ -86,25 +84,4 @@ const InformationBlock = ({ ) } -InformationBlock.propTypes = { - displayName: PropTypes.string, - id: PropTypes.string, - isEmbeddedDashboard: PropTypes.bool, - setDashboardStarred: PropTypes.func, - starred: PropTypes.bool, -} - -const mapStateToProps = (state) => { - const dashboard = sGetSelected(state) - - return { - displayName: dashboard.displayName, - id: dashboard.id, - starred: dashboard.id ? sGetSelectedStarred(state) : false, - isEmbeddedDashboard: sGetSelectedIsEmbedded(state), - } -} - -export default connect(mapStateToProps, { - setDashboardStarred: acSetSelectedStarred, -})(InformationBlock) +export default InformationBlock diff --git a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index 7d40986f8..2d01995a5 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -1,9 +1,11 @@ -import { useDataEngine, useCachedSections } from '@dhis2/app-runtime' +import { useDataEngine, useDhis2ConnectionStatus } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { Input, Menu } from '@dhis2/ui' import cx from 'classnames' +import orderBy from 'lodash/sortBy.js' import PropTypes from 'prop-types' -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react' +import { useSelector } from 'react-redux' import useDebounce from '../../../modules/useDebounce.js' import { EndIntersectionDetector } from './EndIntersectionDetector.js' import { NavigationMenuItem } from './NavigationMenuItem.js' @@ -25,16 +27,29 @@ const dashboardsQuery = { } export const NavigationMenu = ({ close, hasDashboards }) => { - const { cachedSections } = useCachedSections() + const { isDisconnected: offline } = useDhis2ConnectionStatus() const dataEngine = useDataEngine() + const unorderedOfflineDashboards = useSelector( + (state) => state.offlineDashboards + ) const [initialFetchComplete, setInitialFetchComplete] = useState(null) const [dashboards, setDashboards] = useState([]) const [requestParams, setRequestParams] = useState({ page: 1, filterText: '', }) + const offlineDashboards = useMemo( + () => + orderBy(unorderedOfflineDashboards, [ + ['starred', 'displayName'], + 'desc', + 'asc', + ]), + [unorderedOfflineDashboards] + ) + + // console.log('jj NavMenu ordered offlineDashboards', offlineDashboards) const debouncedRequestParams = useDebounce(requestParams, 300) - console.log('jj cachedSections', cachedSections) useEffect(() => { const fetchDashboards = async () => { @@ -69,10 +84,15 @@ export const NavigationMenu = ({ close, hasDashboards }) => { } } - if (debouncedRequestParams.page !== null) { + if (!offline && debouncedRequestParams.page !== null) { fetchDashboards() } - }, [dataEngine, debouncedRequestParams]) + + if (offline) { + setDashboards(offlineDashboards) + setInitialFetchComplete(true) + } + }, [dataEngine, debouncedRequestParams, offline, offlineDashboards]) const onFilterChange = useCallback(({ value }) => { setRequestParams({ page: 1, filterText: value }) diff --git a/src/modules/getCacheableSectionId.js b/src/modules/getCacheableSectionId.js index 376864ab8..e6900f9b9 100644 --- a/src/modules/getCacheableSectionId.js +++ b/src/modules/getCacheableSectionId.js @@ -1 +1,15 @@ -export default (userId, dashboardId) => `${userId}-${dashboardId}` +export const getCacheableSectionId = (userId, dashboardId) => + `${userId}-${dashboardId}` + +export const getOfflineDashboardIds = (userId, cachedSections) => { + const dashboardIds = [] + + for (const key in cachedSections) { + const [part1, part2] = key.split('-') + if (part1 === userId) { + dashboardIds.push(part2) + } + } + + return dashboardIds.sort() +} diff --git a/src/modules/useCacheableSection.js b/src/modules/useCacheableSection.js index f68d9884d..c6f2cb655 100644 --- a/src/modules/useCacheableSection.js +++ b/src/modules/useCacheableSection.js @@ -1,6 +1,6 @@ import { useCachedDataQuery } from '@dhis2/analytics' import { useCacheableSection as useCacheableSectionAppRuntime } from '@dhis2/app-runtime' -import getCacheableSectionId from './getCacheableSectionId.js' +import { getCacheableSectionId } from './getCacheableSectionId.js' export const useCacheableSection = (dashboardId) => { const { currentUser } = useCachedDataQuery() diff --git a/src/modules/useSupersetDashboardMutation.js b/src/modules/useSupersetDashboardMutation.js index c1a90ba43..70d3e6d3b 100644 --- a/src/modules/useSupersetDashboardMutation.js +++ b/src/modules/useSupersetDashboardMutation.js @@ -3,7 +3,6 @@ import i18n from '@dhis2/d2-i18n' import { useCallback, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' -import { tFetchDashboards } from '../actions/dashboards.js' import { acClearSelected, tSetSelectedDashboardById, @@ -82,7 +81,6 @@ export const useSupersetDashboardMutation = ({ closeModal }) => { setMutationLoading(true) await deleteDashboard() dispatch(acClearSelected()) - await dispatch(tFetchDashboards()) setMutationLoading(false) setShowDeleteConfirmDialog(false) closeModal() diff --git a/src/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index e4618358e..29ea8fc3a 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -1,18 +1,27 @@ import { useCachedDataQuery } from '@dhis2/analytics' -import { CacheableSection, useDataEngine } from '@dhis2/app-runtime' +import { + CacheableSection, + useDataEngine, + useCachedSections, +} from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useState, useMemo } from 'react' import { useSelector, useDispatch } from 'react-redux' +import { acSetOfflineDashboards } from '../../actions/offlineDashboards.js' import { acClearSelected } from '../../actions/selected.js' +import { viewDashboardQuery } from '../../api/fetchDashboard.js' import { firstDashboardQuery, - requestedDashboardQuery, + dashboardsByIdsQuery, } from '../../api/fetchDashboards.js' import DashboardsBar from '../../components/DashboardsBar/index.js' import LoadingMask from '../../components/LoadingMask.js' import NoContentMessage from '../../components/NoContentMessage.js' -import getCacheableSectionId from '../../modules/getCacheableSectionId.js' +import { + getCacheableSectionId, + getOfflineDashboardIds, +} from '../../modules/getCacheableSectionId.js' import { getPreferredDashboardId } from '../../modules/localStorage.js' import { sGetSelectedId } from '../../reducers/selected.js' import ViewDashboard from './ViewDashboard.js' @@ -20,8 +29,13 @@ import ViewDashboard from './ViewDashboard.js' const NO_DASHBOARDS_FOUND = 'NO_DASHBOARDS_FOUND' const REQUESTED_DASHBOARD_NOT_FOUND = 'REQUESTED_DASHBOARD_NOT_FOUND' +const requestedDashboardQuery = { + dashboard: viewDashboardQuery, +} + const CacheableViewDashboard = ({ match }) => { const { currentUser } = useCachedDataQuery() + const { cachedSections } = useCachedSections() const engine = useDataEngine() const dispatch = useDispatch() const [idToLoad, setIdToLoad] = useState(null) @@ -33,6 +47,21 @@ const CacheableViewDashboard = ({ match }) => { // match comes from react-router-dom const routeId = match?.params?.dashboardId || null + const offlineDashboardIds = useMemo( + () => getOfflineDashboardIds(currentUser.id, cachedSections), + [currentUser.id, cachedSections] + ) + + useEffect(() => { + console.log('jj CVD request offline dashboards', offlineDashboardIds) + engine.query(dashboardsByIdsQuery, { + variables: { ids: offlineDashboardIds }, + onComplete: (data) => { + dispatch(acSetOfflineDashboards(data.dashboards.dashboards)) + }, + }) + }, [engine, dispatch, offlineDashboardIds]) + useEffect(() => { if (routeId === null && preferredId === null && currentId !== null) { dispatch(acClearSelected()) @@ -60,6 +89,13 @@ const CacheableViewDashboard = ({ match }) => { } catch (error) { const { dashboards } = await engine.query(firstDashboardQuery) + // this is required for offline dashboards + // if (!routeId && !preferredId && dashboards.dashboards[0]) { + // engine.query(requestedDashboardQuery, { + // variables: { id: dashboards.dashboards[0].id }, + // }) + // } + setHasDashboards(dashboards.dashboards.length > 0) if (!routeId) { diff --git a/src/reducers/index.js b/src/reducers/index.js index 9636fb0d7..3af7bbae3 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -6,6 +6,7 @@ import iframePluginStatus from './iframePluginStatus.js' import itemActiveTypes from './itemActiveTypes.js' import itemFilters from './itemFilters.js' import messages from './messages.js' +import offlineDashboards from './offlineDashboards.js' import passiveViewRegistered from './passiveViewRegistered.js' import printDashboard from './printDashboard.js' import selected from './selected.js' @@ -19,6 +20,7 @@ export default combineReducers({ messages, editDashboard, printDashboard, + offlineDashboards, itemFilters, dimensions, activeModalDimension,