diff --git a/i18n/en.pot b/i18n/en.pot index e707c3631..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-07T10:20:57.831Z\n" -"PO-Revision-Date: 2025-02-07T10:20:57.833Z\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,12 +560,12 @@ 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" +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 "No description" msgstr "No description" @@ -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/actions/dashboards.js b/src/actions/dashboards.js deleted file mode 100644 index 69af6b4f9..000000000 --- a/src/actions/dashboards.js +++ /dev/null @@ -1,33 +0,0 @@ -import { apiFetchDashboards } from '../api/fetchAllDashboards.js' -import { arrayToIdMap } from '../modules/util.js' -import { - SET_DASHBOARDS, - ADD_DASHBOARDS, - SET_DASHBOARD_STARRED, -} 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), -}) - -export const acSetDashboardStarred = (id, isStarred) => ({ - type: SET_DASHBOARD_STARRED, - id, - value: isStarred, -}) - -// 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/actions/selected.js b/src/actions/selected.js index 397e692ff..068f0ccdb 100644 --- a/src/actions/selected.js +++ b/src/actions/selected.js @@ -6,9 +6,9 @@ import { storePreferredDashboardId } from '../modules/localStorage.js' import { SET_SELECTED, CLEAR_SELECTED, + SET_SELECTED_STARRED, 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' @@ -24,21 +24,17 @@ 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) => { 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/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/App.js b/src/components/App.js index 66e12a726..79f36bd10 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 && ( @@ -56,10 +45,7 @@ const App = (props) => { systemSettings.startModuleEnableLightweight ? ( ) : ( - + ) } /> @@ -81,12 +67,7 @@ const App = (props) => { ( - - )} + render={(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..0490521ed 100644 --- a/src/components/DashboardsBar/InformationBlock/InformationBlock.js +++ b/src/components/DashboardsBar/InformationBlock/InformationBlock.js @@ -3,9 +3,11 @@ 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 { sGetDashboardStarred } from '../../../reducers/dashboards.js' -import { sGetSelected } from '../../../reducers/selected.js' +import { acSetSelectedStarred } from '../../../actions/selected.js' +import { + sGetSelected, + sGetSelectedStarred, +} from '../../../reducers/selected.js' import ActionsBar from './ActionsBar.js' import { apiStarDashboard } from './apiStarDashboard.js' import LastUpdatedTag from './LastUpdatedTag.js' @@ -28,7 +30,7 @@ const InformationBlock = ({ () => apiStarDashboard(dataEngine, id, !starred) .then(() => { - setDashboardStarred(id, !starred) + setDashboardStarred(!starred) }) .catch(() => { const msg = starred @@ -77,12 +79,10 @@ const mapStateToProps = (state) => { return { displayName: dashboard.displayName, id: dashboard.id, - starred: dashboard.id - ? sGetDashboardStarred(state, dashboard.id) - : false, + starred: dashboard.id ? sGetSelectedStarred(state) : false, } } export default connect(mapStateToProps, { - setDashboardStarred: acSetDashboardStarred, + setDashboardStarred: acSetSelectedStarred, })(InformationBlock) 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/NavigationMenu.js b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js index b42e8a21a..7099c6296 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.js @@ -1,39 +1,106 @@ +import { useDataEngine } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { Input, Menu } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useCallback, useMemo, useEffect, useRef } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } 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: 40, + 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, setHasDashboards] = useState(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) + setHasDashboards(!!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 +112,7 @@ export const NavigationMenu = ({ close }) => { }) }, []) - if (dashboards.length === 0) { + if (hasDashboards === false && !filterText) { return ( {i18n.t('No dashboards available.')} @@ -68,7 +135,7 @@ export const NavigationMenu = ({ close }) => { - {filteredDashboards.length === 0 ? ( + {initialFetchComplete && state.dashboards.length === 0 ? ( {i18n.t( 'No dashboards found for "{{- filterText}}"', @@ -78,23 +145,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/__tests__/NavigationMenu.spec.js b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.js index 588774b69..8f80b382b 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,197 @@ 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(), })) + +jest.mock('../../../../actions/dashboardsFilter', () => ({ + acSetDashboardsFilter: jest.fn(), +})) + +jest.mock('../EndIntersectionDetector.js', () => { + const React = require('react') + 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', () => { + return { + NavigationMenuItem: ({ displayName }) => ( + {displayName} + ), + } +}) + 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, - }, - }, 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('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('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('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/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/__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() }) 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 f5844aafa..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, @@ -10,7 +14,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, @@ -21,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, @@ -46,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) @@ -82,8 +87,7 @@ const EditBar = ({ dashboard, ...props }) => { }) .then(() => { props.clearSelected() - - return props.fetchDashboards() + removePreferredDashboardId(currentUser.username) }) .then(() => setRedirectUrl('/')) .catch(deleteFailureAlert.show) @@ -333,7 +337,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/pages/view/CacheableViewDashboard.js b/src/pages/view/CacheableViewDashboard.js index 8f951c68d..7b5ed7a9b 100644 --- a/src/pages/view/CacheableViewDashboard.js +++ b/src/pages/view/CacheableViewDashboard.js @@ -1,67 +1,149 @@ import { useCachedDataQuery } from '@dhis2/analytics' -import { CacheableSection } from '@dhis2/app-runtime' +import { CacheableSection, useDataEngine } 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' +import React, { useEffect, useState } from 'react' +import { useSelector, useDispatch } from 'react-redux' import { acClearSelected } from '../../actions/selected.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 { 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 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' + +const CacheableViewDashboard = ({ match }) => { const { currentUser } = useCachedDataQuery() + const engine = useDataEngine() + const dispatch = useDispatch() + 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 + const routeId = match?.params?.dashboardId || null useEffect(() => { - if (id === null && selectedId !== null) { - clearSelectedDashboard() + if (routeId === null && preferredId === null && selectedId !== null) { + dispatch(acClearSelected()) } - }, [id, selectedId, clearSelectedDashboard]) + }, [routeId, preferredId, selectedId, dispatch]) - if (!dashboardsLoaded) { - return - } + useEffect(() => { + const fetchIdToLoad = async () => { + try { + if (!routeId && !preferredId) { + const { dashboards } = await engine.query( + firstDashboardQuery + ) + if (dashboards.dashboards.length === 0) { + setFetchError(NO_DASHBOARDS_FOUND) + 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: preferredId }, + } + ) + setIdToLoad(dashboard.id) + return + } catch (error) { + 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) + setIdToLoad(null) + return + } + const firstDashboardId = dashboards?.dashboards[0]?.id + setIdToLoad(firstDashboardId) + } + } + setFetchError(null) + + fetchIdToLoad() + }, [engine, routeId, preferredId]) - if (dashboardsIsEmpty || id === null) { + if (fetchError) { return ( <> + > ) } - const cacheSectionId = getCacheableSectionId(currentUser.id, id) + console.log('jj ', { idToLoad, fetchError, routeId, preferredId }) + + if (idToLoad === null) { + return + } + + const cacheSectionId = getCacheableSectionId(currentUser.id, idToLoad) return ( }> @@ -69,40 +151,7 @@ const CacheableViewDashboard = ({ } CacheableViewDashboard.propTypes = { - clearSelectedDashboard: PropTypes.func, - dashboardsIsEmpty: PropTypes.bool, - dashboardsLoaded: PropTypes.bool, - id: PropTypes.string, - selectedId: PropTypes.string, -} - -const mapStateToProps = (state, ownProps) => { - 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] - } - - return { - dashboardsIsEmpty: isEmpty(dashboards), - dashboardsLoaded: !sDashboardsIsFetching(state), - id: dashboardToSelect?.id || null, - selectedId: sGetSelectedId(state) || null, - } -} - -const mapDispatchToProps = { - clearSelectedDashboard: acClearSelected, + match: PropTypes.object, } -export default connect( - mapStateToProps, - mapDispatchToProps -)(CacheableViewDashboard) +export default CacheableViewDashboard 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 deleted file mode 100644 index 10b67d3ef..000000000 --- a/src/reducers/__tests__/dashboards.spec.js +++ /dev/null @@ -1,140 +0,0 @@ -import reducer, { - DEFAULT_STATE_DASHBOARDS, - sGetDashboardsRoot, - sGetDashboardById, - sGetAllDashboards, - sGetDashboardsSortedByStarred, - SET_DASHBOARDS, - ADD_DASHBOARDS, - SET_DASHBOARD_STARRED, -} 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) - }) - - 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 = { - 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('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) - - expect(actualState).toEqual([dash4, dash2, dash3, dash1]) - }) -}) diff --git a/src/reducers/dashboards.js b/src/reducers/dashboards.js deleted file mode 100644 index 91bc17089..000000000 --- a/src/reducers/dashboards.js +++ /dev/null @@ -1,100 +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 SET_DASHBOARD_STARRED = 'SET_DASHBOARD_STARRED' - -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, - } - } - case SET_DASHBOARD_STARRED: { - return { - ...state, - [action.id]: { - ...state[action.id], - starred: action.value, - }, - } - } - default: - return state - } -} - -// root selector - -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] - -export const sGetDashboardStarred = (state, id) => - sGetDashboardById(state, id).starred - -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)) - -// 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, diff --git a/src/reducers/selected.js b/src/reducers/selected.js index df772d96f..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 = { @@ -12,6 +13,7 @@ const SELECTED_PROPERTIES = { dashboardItems: [], layout: [], itemConfig: {}, + starred: false, } export default (state = DEFAULT_SELECTED_STATE, action) => { @@ -26,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 } @@ -37,6 +45,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
{i18n.t('No dashboards available.')}