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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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.')}