diff --git a/cypress/support/utils.js b/cypress/support/utils.js index 05ef1f351..8fcd050a4 100644 --- a/cypress/support/utils.js +++ b/cypress/support/utils.js @@ -1,4 +1,4 @@ -export const EXTENDED_TIMEOUT = { timeout: 25000 } +export const EXTENDED_TIMEOUT = { timeout: 15000 } export const getApiBaseUrl = () => { const baseUrl = Cypress.env('dhis2BaseUrl') || '' diff --git a/i18n/en.pot b/i18n/en.pot index 486c05902..0cddc3a9e 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -679,12 +679,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" 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/dashboardsFilter.js b/src/actions/dashboardsFilter.js deleted file mode 100644 index 276b96926..000000000 --- a/src/actions/dashboardsFilter.js +++ /dev/null @@ -1,15 +0,0 @@ -import { - SET_DASHBOARDS_FILTER, - CLEAR_DASHBOARDS_FILTER, -} from '../reducers/dashboardsFilter.js' - -// actions - -export const acSetDashboardsFilter = (value) => ({ - type: SET_DASHBOARDS_FILTER, - value, -}) - -export const acClearDashboardsFilter = () => ({ - type: CLEAR_DASHBOARDS_FILTER, -}) diff --git a/src/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/offlineDashboards.js b/src/actions/offlineDashboards.js new file mode 100644 index 000000000..51e89deba --- /dev/null +++ b/src/actions/offlineDashboards.js @@ -0,0 +1,15 @@ +import { + SET_OFFLINE_DASHBOARDS, + STAR_OFFLINE_DASHBOARD, +} from '../reducers/offlineDashboards.js' + +// Action creators +export const acSetOfflineDashboards = (dashboards) => ({ + type: SET_OFFLINE_DASHBOARDS, + dashboards, +}) + +export const acSetOfflineDashboardStarred = (value) => ({ + type: STAR_OFFLINE_DASHBOARD, + value, +}) 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/fetchDashboards.js b/src/api/fetchDashboards.js new file mode 100644 index 000000000..f8480b929 --- /dev/null +++ b/src/api/fetchDashboards.js @@ -0,0 +1,35 @@ +export const firstDashboardQuery = { + dashboards: { + resource: 'dashboards', + params: { + fields: 'id,displayName', + order: 'favorite:desc,displayName:asc', + paging: true, + pageSize: 1, + }, + }, +} + +export const requestedDashboardQuery = { + dashboard: { + resource: 'dashboards', + id: ({ id }) => id, + params: { + fields: ['id', 'displayName'], + }, + }, +} + +export const dashboardsByIdsQuery = { + dashboards: { + resource: 'dashboards', + params: ({ ids }) => { + return { + fields: 'id,displayName,favorite~rename(starred)', + order: 'favorite:desc,displayName:asc', + filter: ids ? `id:in:[${ids.join(',')}]` : undefined, + paging: false, + } + }, + }, +} diff --git a/src/components/App.jsx b/src/components/App.jsx index f6cf7d302..35621c502 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -4,8 +4,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' import { acClearItemFilters } from '../actions/itemFilters.js' @@ -26,14 +24,13 @@ import 'react-grid-layout/css/styles.css' import 'react-resizable/css/styles.css' import './styles/ItemGrid.css' -const App = ({ fetchDashboards, setShowDescription, resetState }) => { +const App = ({ setShowDescription, resetState }) => { const systemSettings = useSystemSettings() const currentUser = useCurrentUser() useEffect(() => { - fetchDashboards() setShowDescription() - }, [fetchDashboards, setShowDescription]) + }, [setShowDescription]) return ( systemSettings && ( @@ -48,10 +45,7 @@ const App = ({ fetchDashboards, setShowDescription, resetState }) => { systemSettings.startModuleEnableLightweight ? ( ) : ( - + ) } /> @@ -73,12 +67,7 @@ const App = ({ fetchDashboards, setShowDescription, resetState }) => { ( - - )} + render={(props) => } /> { } App.propTypes = { - fetchDashboards: PropTypes.func, resetState: PropTypes.func, setShowDescription: PropTypes.func, } const mapDispatchToProps = { - fetchDashboards: tFetchDashboards, setShowDescription: tSetShowDescription, resetState: () => (dispatch) => { dispatch(acSetSelected({})) - dispatch(acClearDashboardsFilter()) dispatch(acClearVisualizations()) dispatch(acClearEditDashboard()) dispatch(acClearPrintDashboard()) diff --git a/src/components/DashboardsBar/ConfigureSupersetDashboard/CreateSupersetDashboardModal.jsx b/src/components/DashboardsBar/ConfigureSupersetDashboard/CreateSupersetDashboardModal.jsx index f0edd21e4..3a74d9cd1 100644 --- a/src/components/DashboardsBar/ConfigureSupersetDashboard/CreateSupersetDashboardModal.jsx +++ b/src/components/DashboardsBar/ConfigureSupersetDashboard/CreateSupersetDashboardModal.jsx @@ -10,9 +10,7 @@ import { } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useCallback, useState } from 'react' -import { useDispatch } from 'react-redux' import { useHistory } from 'react-router-dom' -import { tFetchDashboards } from '../../../actions/dashboards.js' import { parseSupersetDashboardFieldValues } from '../../../modules/parseSupersetDashboardFieldValues.js' import { useSupersetDashboardFieldsState } from '../../../modules/useSupersetDashboardFieldsState.js' import styles from './styles/SupersetDashboardModal.module.css' @@ -29,7 +27,6 @@ export const CreateSupersetDashboardModal = ({ backToChooseDashboardModal, closeModal, }) => { - const dispatch = useDispatch() const history = useHistory() const [loading, setLoading] = useState(false) const [postDashboard, { error }] = useDataMutation(postDashboardQuery, { @@ -49,12 +46,11 @@ export const CreateSupersetDashboardModal = ({ event.preventDefault() setLoading(true) const { response } = await postDashboard({ values }) - await dispatch(tFetchDashboards()) setLoading(false) closeModal() history.push(`/${response.uid}`) }, - [values, postDashboard, closeModal, dispatch, history] + [values, postDashboard, closeModal, history] ) return ( diff --git a/src/components/DashboardsBar/InformationBlock/InformationBlock.jsx b/src/components/DashboardsBar/InformationBlock/InformationBlock.jsx index b4e70e375..be08f43d6 100644 --- a/src/components/DashboardsBar/InformationBlock/InformationBlock.jsx +++ b/src/components/DashboardsBar/InformationBlock/InformationBlock.jsx @@ -1,15 +1,11 @@ import { useAlert, useDataEngine } from '@dhis2/app-runtime' 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 { useDispatch, useSelector } from 'react-redux' +import { acSetOfflineDashboardStarred } from '../../../actions/offlineDashboards.js' +import { acSetSelectedStarred } from '../../../actions/selected.js' import { isSmallScreen } from '../../../modules/smallScreen.js' -import { sGetDashboardStarred } from '../../../reducers/dashboards.js' -import { - sGetSelected, - sGetSelectedIsEmbedded, -} from '../../../reducers/selected.js' +import { sGetSelected } from '../../../reducers/selected.js' import { useWindowDimensions } from '../../WindowDimensionsProvider.jsx' import ActionsBar from './ActionsBar.jsx' import { apiStarDashboard } from './apiStarDashboard.js' @@ -18,14 +14,12 @@ import LastUpdatedTag from './LastUpdatedTag.jsx' import StarDashboardButton from './StarDashboardButton.jsx' import classes from './styles/InformationBlock.module.css' -const InformationBlock = ({ - id, - isEmbeddedDashboard, - displayName, - starred, - setDashboardStarred, -}) => { +const InformationBlock = () => { const dataEngine = useDataEngine() + const dispatch = useDispatch() + const { id, displayName, starred, embedded } = useSelector(sGetSelected) + const isEmbeddedDashboard = !!embedded + const { show: showAlert } = useAlert( ({ msg }) => msg, ({ isCritical }) => @@ -35,7 +29,10 @@ const InformationBlock = ({ () => apiStarDashboard(dataEngine, id, !starred) .then(() => { - setDashboardStarred(id, !starred) + dispatch(acSetSelectedStarred(!starred)) + dispatch( + acSetOfflineDashboardStarred({ id, starred: !starred }) + ) }) .catch(() => { const msg = starred @@ -43,7 +40,7 @@ const InformationBlock = ({ : i18n.t('Failed to star the dashboard') showAlert({ msg, isCritical: false }) }), - [dataEngine, id, setDashboardStarred, showAlert, starred] + [dataEngine, id, showAlert, starred, dispatch] ) const { width } = useWindowDimensions() @@ -76,27 +73,4 @@ const InformationBlock = ({ ) } -InformationBlock.propTypes = { - displayName: PropTypes.string, - id: PropTypes.string, - isEmbeddedDashboard: PropTypes.bool, - setDashboardStarred: PropTypes.func, - starred: PropTypes.bool, -} - -const mapStateToProps = (state) => { - const dashboard = sGetSelected(state) - - return { - displayName: dashboard.displayName, - id: dashboard.id, - starred: dashboard.id - ? sGetDashboardStarred(state, dashboard.id) - : false, - isEmbeddedDashboard: sGetSelectedIsEmbedded(state), - } -} - -export default connect(mapStateToProps, { - setDashboardStarred: acSetDashboardStarred, -})(InformationBlock) +export default InformationBlock diff --git a/src/components/DashboardsBar/NavigationMenu/EndIntersectionDetector.jsx b/src/components/DashboardsBar/NavigationMenu/EndIntersectionDetector.jsx new file mode 100644 index 000000000..a1776699b --- /dev/null +++ b/src/components/DashboardsBar/NavigationMenu/EndIntersectionDetector.jsx @@ -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.jsx b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.jsx index 9627626e3..52844ebd0 100644 --- a/src/components/DashboardsBar/NavigationMenu/NavigationMenu.jsx +++ b/src/components/DashboardsBar/NavigationMenu/NavigationMenu.jsx @@ -1,39 +1,124 @@ +import { useDataEngine, useDhis2ConnectionStatus } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { Input, Menu } from '@dhis2/ui' import cx from 'classnames' +import orderBy from 'lodash/sortBy.js' import PropTypes from 'prop-types' -import React, { useCallback, useMemo, useEffect, useRef } 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 React, { useCallback, useEffect, useRef, useState, useMemo } from 'react' +import { useSelector } from 'react-redux' +import useDebounce from '../../../modules/useDebounce.js' +import { EndIntersectionDetector } from './EndIntersectionDetector.jsx' import { NavigationMenuItem } from './NavigationMenuItem.jsx' import styles from './styles/NavigationMenu.module.css' import itemStyles from './styles/NavigationMenuItem.module.css' -export const NavigationMenu = ({ close }) => { - const dispatch = useDispatch() - const scrollBoxRef = useRef(null) - const dashboards = useSelector(sGetDashboardsSortedByStarred) - const filterText = useSelector(sGetDashboardsFilter) - const onFilterChange = useCallback( - ({ value }) => { - dispatch(acSetDashboardsFilter(value)) - }, - [dispatch] +const dashboardsQuery = { + resource: 'dashboards', + params: ({ page, filterText }) => { + return { + fields: 'id,displayName,favorite~rename(starred)', + order: 'favorite:desc,displayName:asc', + filter: filterText ? `displayName:ilike:${filterText}` : undefined, + paging: true, + pageSize: 200, + page, + } + }, +} + +export const NavigationMenu = ({ close, hasDashboards }) => { + const { isDisconnected: offline } = useDhis2ConnectionStatus() + const dataEngine = useDataEngine() + const unorderedOfflineDashboards = useSelector( + (state) => state.offlineDashboards ) - const filteredDashboards = useMemo( + const [initialFetchComplete, setInitialFetchComplete] = useState(null) + const [dashboards, setDashboards] = useState([]) + const [requestParams, setRequestParams] = useState({ + page: 1, + filterText: '', + }) + const offlineDashboards = useMemo( () => - dashboards.filter( - (dashboard) => - !filterText || - dashboard.displayName - .toLowerCase() - .includes(filterText.toLowerCase()) - ), - [filterText, dashboards] + orderBy(unorderedOfflineDashboards, [ + ['starred', 'displayName'], + 'desc', + 'asc', + ]), + [unorderedOfflineDashboards] ) + // console.log('jj NavMenu ordered offlineDashboards', offlineDashboards) + const debouncedRequestParams = useDebounce(requestParams, 300) + + useEffect(() => { + const fetchDashboards = async () => { + const { page, filterText } = debouncedRequestParams + const data = await dataEngine.query( + { dashboards: dashboardsQuery }, + { + variables: { + page, + filterText, + }, + } + ) + + const response = { + dashboards: data.dashboards.dashboards, + nextPage: data.dashboards.pager.nextPage + ? data.dashboards.pager.page + 1 + : null, + } + + setInitialFetchComplete(true) + + setDashboards((currentDashboards) => + page > 1 + ? [...currentDashboards, ...response.dashboards] + : response.dashboards + ) + + if (response.nextPage === null) { + setRequestParams({ page: null, filterText }) + } + } + + if (!offline && debouncedRequestParams.page !== null) { + fetchDashboards() + } + + if (offline) { + setDashboards(offlineDashboards) + setInitialFetchComplete(true) + } + }, [dataEngine, debouncedRequestParams, offline, offlineDashboards]) + + const onFilterChange = useCallback(({ value }) => { + setRequestParams({ page: 1, filterText: value }) + + // prevent onEndReached from firing when the user changing filter text + scrollBoxRef.current?.scrollTo({ + top: 0, + left: 0, + behavior: 'smooth', + instant: false, + }) + }, []) + + const onEndReached = useCallback(() => { + setRequestParams((currParams) => ({ + ...currParams, + page: + currParams.page !== null + ? currParams.page + 1 + : currParams.page, + })) + }, []) + + const scrollBoxRef = useRef(null) + + // scroll initially to the selected item useEffect(() => { scrollBoxRef.current ?.getElementsByClassName(itemStyles.selectedItem) @@ -45,9 +130,12 @@ export const NavigationMenu = ({ close }) => { }) }, []) - if (dashboards.length === 0) { + if (!hasDashboards) { return ( -
+

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

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

@@ -61,40 +149,54 @@ export const NavigationMenu = ({ close }) => { dense type="search" placeholder={i18n.t('Search for a dashboard')} - value={filterText} + value={requestParams.filterText} onChange={onFilterChange} initialFocus={true} />
-
- - {filteredDashboards.length === 0 ? ( -
  • - {i18n.t( - 'No dashboards found for "{{- filterText}}"', - { - filterText, - } - )} -
  • - ) : ( - filteredDashboards.map( - ({ displayName, id, starred }) => ( - + + {dashboards.length === 0 ? ( +
  • + {i18n.t( + 'No dashboards found for "{{- filterText}}"', + { + filterText: requestParams.filterText, + } + )} +
  • + ) : ( + <> + {dashboards.map( + ({ displayName, id, starred }) => ( + + ) + )} + - ) - ) - )} -
    -
    + + )} + + + )} ) } + NavigationMenu.propTypes = { close: PropTypes.func.isRequired, + hasDashboards: PropTypes.bool.isRequired, } diff --git a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.jsx b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.jsx index 2d46ce1c7..fc5cd531d 100644 --- a/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.jsx +++ b/src/components/DashboardsBar/NavigationMenu/__tests__/NavigationMenu.spec.jsx @@ -1,90 +1,120 @@ -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' import { Router } from 'react-router-dom' -import { createStore } from 'redux' +import configureStore from 'redux-mock-store' import { NavigationMenu } from '../NavigationMenu.jsx' +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: jest.fn(), + useDhis2ConnectionStatus: jest.fn(() => ({ isDisconnected: false })), +})) + +jest.mock('../EndIntersectionDetector.jsx', () => { + 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.jsx', () => ({ NavigationMenuItem: ( { displayName } // NOSONAR ) =>
  • {displayName}
  • , // NOSONAR })) -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, - }, + +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, }, - dashboardsFilter: '', } -const createMockStore = (state) => - createStore(() => ({ ...baseState, ...state })) +const mockStore = configureStore([]) +const store = mockStore({}) -test('renders a list of dashboard menu items', () => { - const mockStore = createMockStore({}) - const { getAllByRole } = render( - - - {}} /> - - - ) - expect(getAllByRole('presentation')).toHaveLength(5) -}) +describe('NavigationMenu', () => { + let dataEngine -test('renders a notification if no dashboards are available', () => { - const mockStore = createMockStore({ dashboards: {} }) - const { getByText } = render( - - - {}} /> - - - ) + beforeEach(() => { + dataEngine = { + query: jest.fn().mockResolvedValue({ + dashboards: { + dashboards: Object.values(dashboards), + pager: { + page: 1, + nextPage: null, + }, + }, + }), + } + require('@dhis2/app-runtime').useDataEngine.mockReturnValue(dataEngine) + }) - expect(getByText('No dashboards available.')).toBeVisible() - expect( - getByText('Create a new dashboard using the + button.') - ).toBeVisible() -}) + it('renders NavigationMenuItems after fetching dashboards', async () => { + const { getAllByRole } = render( + + + {}} hasDashboards={true} /> + + + ) + + await waitFor(() => { + expect(getAllByRole('presentation')).toHaveLength(5) + }) + }) + + it('renders a notification if no dashboards are available', async () => { + let getByText + await act(async () => { + const renderResult = render( + + + {}} + hasDashboards={false} + /> + + + ) + getByText = renderResult.getByText + }) -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(getByText('No dashboards available.')).toBeVisible() + expect( + getByText('Create a new dashboard using the + button.') + ).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.jsx b/src/components/__tests__/App.spec.jsx index 47c4ff8de..0ea333c61 100644 --- a/src/components/__tests__/App.spec.jsx +++ b/src/components/__tests__/App.spec.jsx @@ -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.jsx' import { useSystemSettings } from '../AppDataProvider/AppDataProvider.jsx' @@ -22,21 +21,10 @@ jest.mock('@dhis2/app-runtime', () => ({ isDisconnected: false, })), useDataEngine: jest.fn(() => ({ query: Function.prototype })), + useDataQuery: jest.fn(() => ({ query: Function.prototype })), 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(() => ({ @@ -129,7 +117,6 @@ test('renders the app with a dashboard', () => { ) expect(container).toMatchSnapshot() - expect(apiFetchDashboards).toHaveBeenCalledTimes(1) jest.clearAllMocks() }) @@ -146,6 +133,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/getCacheableSectionId.js b/src/modules/getCacheableSectionId.js index 376864ab8..e6900f9b9 100644 --- a/src/modules/getCacheableSectionId.js +++ b/src/modules/getCacheableSectionId.js @@ -1 +1,15 @@ -export default (userId, dashboardId) => `${userId}-${dashboardId}` +export const getCacheableSectionId = (userId, dashboardId) => + `${userId}-${dashboardId}` + +export const getOfflineDashboardIds = (userId, cachedSections) => { + const dashboardIds = [] + + for (const key in cachedSections) { + const [part1, part2] = key.split('-') + if (part1 === userId) { + dashboardIds.push(part2) + } + } + + return dashboardIds.sort() +} diff --git a/src/modules/useCacheableSection.js b/src/modules/useCacheableSection.js index e22c7603d..b5cebf331 100644 --- a/src/modules/useCacheableSection.js +++ b/src/modules/useCacheableSection.js @@ -1,6 +1,6 @@ import { useCacheableSection as useCacheableSectionAppRuntime } from '@dhis2/app-runtime' import { useCurrentUser } from '../components/AppDataProvider/AppDataProvider.jsx' -import getCacheableSectionId from './getCacheableSectionId.js' +import { getCacheableSectionId } from './getCacheableSectionId.js' export const useCacheableSection = (dashboardId) => { const currentUser = useCurrentUser() diff --git a/src/modules/useSupersetDashboardMutation.js b/src/modules/useSupersetDashboardMutation.js index c1a90ba43..70d3e6d3b 100644 --- a/src/modules/useSupersetDashboardMutation.js +++ b/src/modules/useSupersetDashboardMutation.js @@ -3,7 +3,6 @@ import i18n from '@dhis2/d2-i18n' import { useCallback, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' -import { tFetchDashboards } from '../actions/dashboards.js' import { acClearSelected, tSetSelectedDashboardById, @@ -82,7 +81,6 @@ export const useSupersetDashboardMutation = ({ closeModal }) => { setMutationLoading(true) await deleteDashboard() dispatch(acClearSelected()) - await dispatch(tFetchDashboards()) setMutationLoading(false) setShowDeleteConfirmDialog(false) closeModal() diff --git a/src/pages/edit/ActionsBar.jsx b/src/pages/edit/ActionsBar.jsx index 8f0e796a1..e7d2f7dcd 100644 --- a/src/pages/edit/ActionsBar.jsx +++ b/src/pages/edit/ActionsBar.jsx @@ -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) @@ -321,7 +318,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/start/LandingPage.jsx b/src/pages/start/LandingPage.jsx index f11a22666..65f8213c9 100644 --- a/src/pages/start/LandingPage.jsx +++ b/src/pages/start/LandingPage.jsx @@ -1,16 +1,22 @@ +import { useDataQuery } from '@dhis2/app-runtime' import PropTypes from 'prop-types' import React, { useEffect } from 'react' +import { firstDashboardQuery } from '../../api/fetchDashboards.js' import DashboardsBar from '../../components/DashboardsBar/index.js' import StartScreen from './StartScreen.jsx' const LandingPage = ({ username, onMount }) => { + const { data } = useDataQuery(firstDashboardQuery) + useEffect(() => { onMount() - }, []) + }, [onMount]) return ( <> - + ) diff --git a/src/pages/view/CacheableViewDashboard.jsx b/src/pages/view/CacheableViewDashboard.jsx index 57ba5ced0..e1fcbdc17 100644 --- a/src/pages/view/CacheableViewDashboard.jsx +++ b/src/pages/view/CacheableViewDashboard.jsx @@ -1,108 +1,162 @@ -import { CacheableSection } from '@dhis2/app-runtime' +import { useCachedDataQuery } from '@dhis2/analytics' +import { + CacheableSection, + useDataEngine, + useCachedSections, +} 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, useMemo } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { acSetOfflineDashboards } from '../../actions/offlineDashboards.js' import { acClearSelected } from '../../actions/selected.js' -import { useCurrentUser } from '../../components/AppDataProvider/AppDataProvider.jsx' +import { viewDashboardQuery } from '../../api/fetchDashboard.js' +import { + firstDashboardQuery, + dashboardsByIdsQuery, +} from '../../api/fetchDashboards.js' import DashboardsBar from '../../components/DashboardsBar/index.js' import LoadingMask from '../../components/LoadingMask.jsx' import NoContentMessage from '../../components/NoContentMessage.jsx' -import getCacheableSectionId from '../../modules/getCacheableSectionId.js' -import { getPreferredDashboardId } from '../../modules/localStorage.js' import { - sDashboardsIsFetching, - sGetDashboardById, - sGetDashboardsSortedByStarred, -} from '../../reducers/dashboards.js' + getCacheableSectionId, + getOfflineDashboardIds, +} from '../../modules/getCacheableSectionId.js' +import { getPreferredDashboardId } from '../../modules/localStorage.js' import { sGetSelectedId } from '../../reducers/selected.js' import ViewDashboard from './ViewDashboard.jsx' -const CacheableViewDashboard = ({ - clearSelectedDashboard, - dashboardsIsEmpty, - dashboardsLoaded, - id, - selectedId, -}) => { - const currentUser = useCurrentUser() +const NO_DASHBOARDS_FOUND = 'NO_DASHBOARDS_FOUND' +const REQUESTED_DASHBOARD_NOT_FOUND = 'REQUESTED_DASHBOARD_NOT_FOUND' + +const requestedDashboardQuery = { + dashboard: viewDashboardQuery, +} + +const CacheableViewDashboard = ({ match }) => { + const { currentUser } = useCachedDataQuery() + const { cachedSections } = useCachedSections() + const engine = useDataEngine() + const dispatch = useDispatch() + const [idToLoad, setIdToLoad] = useState(null) + const [dashboardName, setDashboardName] = useState(null) + const [fetchError, setFetchError] = useState(null) + const [hasDashboards, setHasDashboards] = useState(null) + const currentId = useSelector(sGetSelectedId) + const preferredId = getPreferredDashboardId(currentUser.username) || null + // match comes from react-router-dom + const routeId = match?.params?.dashboardId || null + + const offlineDashboardIds = useMemo( + () => getOfflineDashboardIds(currentUser.id, cachedSections), + [currentUser.id, cachedSections] + ) + + useEffect(() => { + console.log('jj CVD request offline dashboards', offlineDashboardIds) + engine.query(dashboardsByIdsQuery, { + variables: { ids: offlineDashboardIds }, + onComplete: (data) => { + dispatch(acSetOfflineDashboards(data.dashboards.dashboards)) + }, + }) + }, [engine, dispatch, offlineDashboardIds]) useEffect(() => { - if (id === null && selectedId !== null) { - clearSelectedDashboard() + if (routeId === null && preferredId === null && currentId !== null) { + dispatch(acClearSelected()) } - }, [id, selectedId, clearSelectedDashboard]) + }, [routeId, preferredId, currentId, dispatch]) - if (!dashboardsLoaded) { - return - } + useEffect(() => { + const fetchIdToLoad = async () => { + try { + // no id, so fetch the first starred/alphabetical dashboard in the catch block + if (!routeId && !preferredId) { + throw new Error('No dashboard id provided') + } + + // get the dashboard by id, throws an error if the dashboard is not found + const { dashboard } = await engine.query( + requestedDashboardQuery, + { + variables: { id: routeId || preferredId }, + } + ) + setDashboardName(dashboard.displayName) + setIdToLoad(dashboard.id) + setHasDashboards(true) + } catch (error) { + const { dashboards } = await engine.query(firstDashboardQuery) - if (dashboardsIsEmpty || id === null) { + // this is required for offline dashboards + // if (!routeId && !preferredId && dashboards.dashboards[0]) { + // engine.query(requestedDashboardQuery, { + // variables: { id: dashboards.dashboards[0].id }, + // }) + // } + + setHasDashboards(dashboards.dashboards.length > 0) + + if (!routeId) { + setDashboardName( + dashboards.dashboards[0]?.displayName || null + ) + setIdToLoad(dashboards.dashboards[0]?.id || null) + setFetchError( + !dashboards.dashboards.length && NO_DASHBOARDS_FOUND + ) + } else { + setFetchError(REQUESTED_DASHBOARD_NOT_FOUND) + } + } + } + + setIdToLoad(null) + setDashboardName(null) + setFetchError(null) + + fetchIdToLoad() + }, [engine, routeId, preferredId]) + + if (fetchError) { return ( <> - + ) } - const cacheSectionId = getCacheableSectionId(currentUser.id, id) + if (idToLoad === null) { + return + } + + const cacheSectionId = getCacheableSectionId(currentUser.id, idToLoad) return ( }> ) } 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.jsx b/src/pages/view/ViewDashboard.jsx index e4a6a16a5..0e035302a 100644 --- a/src/pages/view/ViewDashboard.jsx +++ b/src/pages/view/ViewDashboard.jsx @@ -20,7 +20,6 @@ import DashboardContainer from '../../components/DashboardContainer.jsx' 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' @@ -30,10 +29,12 @@ const ViewDashboard = ({ clearEditDashboard, clearPrintDashboard, fetchDashboard, + hasDashboards, passiveViewRegistered, registerPassiveView, requestedDashboardName, requestedId, + currentId, setSelectedAsOffline, username, }) => { @@ -135,11 +136,11 @@ const ViewDashboard = ({ className={cx(classes.container, 'dashboard-scroll-container')} data-test="outer-scroll-container" > - + @@ -151,7 +152,9 @@ const ViewDashboard = ({ ViewDashboard.propTypes = { clearEditDashboard: PropTypes.func, clearPrintDashboard: PropTypes.func, + currentId: PropTypes.string, fetchDashboard: PropTypes.func, + hasDashboards: PropTypes.bool, passiveViewRegistered: PropTypes.bool, registerPassiveView: PropTypes.func, requestedDashboardName: PropTypes.string, @@ -160,12 +163,9 @@ ViewDashboard.propTypes = { 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/__tests__/dashboardsFilter.spec.js b/src/reducers/__tests__/dashboardsFilter.spec.js deleted file mode 100644 index 5587fbf25..000000000 --- a/src/reducers/__tests__/dashboardsFilter.spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import reducer, { - DEFAULT_STATE_DASHBOARDS_FILTER, - SET_DASHBOARDS_FILTER, - CLEAR_DASHBOARDS_FILTER, - sGetDashboardsFilter, -} from '../dashboardsFilter.js' - -describe('dashboards filter reducer', () => { - it('returns the default state when action type unrecognized', () => { - const actualState = reducer(DEFAULT_STATE_DASHBOARDS_FILTER, {}) - - expect(actualState).toEqual(DEFAULT_STATE_DASHBOARDS_FILTER) - }) - - it('returns the default state when value is null', () => { - const actualState = reducer(DEFAULT_STATE_DASHBOARDS_FILTER, { - type: SET_DASHBOARDS_FILTER, - value: null, - }) - - expect(actualState).toEqual(DEFAULT_STATE_DASHBOARDS_FILTER) - }) - - it('returns the default state when value is undefined', () => { - const actualState = reducer(DEFAULT_STATE_DASHBOARDS_FILTER, { - type: SET_DASHBOARDS_FILTER, - }) - - expect(actualState).toEqual(DEFAULT_STATE_DASHBOARDS_FILTER) - }) - - it('sets the filter', () => { - const action = { - type: SET_DASHBOARDS_FILTER, - value: 'rainbowdash', - } - - const actualState = reducer(undefined, action) - - expect(actualState).toEqual('rainbowdash') - }) - - it('clears the filter', () => { - const action = { - type: CLEAR_DASHBOARDS_FILTER, - } - - const currentState = 'rainbow' - - const actualState = reducer(currentState, action) - - expect(actualState).toEqual(DEFAULT_STATE_DASHBOARDS_FILTER) - }) - - it('gets the current filter from state', () => { - const filterText = 'rainbow' - const action = { - type: SET_DASHBOARDS_FILTER, - value: filterText, - } - const dashboardsFilter = reducer(null, action) - const filterInState = sGetDashboardsFilter({ dashboardsFilter }) - - expect(filterInState).toEqual(filterText) - }) -}) diff --git a/src/reducers/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/dashboardsFilter.js b/src/reducers/dashboardsFilter.js deleted file mode 100644 index 637b32d00..000000000 --- a/src/reducers/dashboardsFilter.js +++ /dev/null @@ -1,26 +0,0 @@ -import { validateReducer } from '../modules/util.js' - -export const SET_DASHBOARDS_FILTER = 'SET_DASHBOARDS_FILTER' -export const CLEAR_DASHBOARDS_FILTER = 'CLEAR_DASHBOARDS_FILTER' - -export const DEFAULT_STATE_DASHBOARDS_FILTER = '' - -export default (state = DEFAULT_STATE_DASHBOARDS_FILTER, action) => { - switch (action.type) { - case SET_DASHBOARDS_FILTER: { - return validateReducer( - action.value, - DEFAULT_STATE_DASHBOARDS_FILTER - ) - } - case CLEAR_DASHBOARDS_FILTER: { - return DEFAULT_STATE_DASHBOARDS_FILTER - } - default: - return state - } -} - -// selectors - -export const sGetDashboardsFilter = (state) => state.dashboardsFilter diff --git a/src/reducers/index.js b/src/reducers/index.js index 1f5537462..3af7bbae3 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,13 +1,12 @@ 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' import iframePluginStatus from './iframePluginStatus.js' import itemActiveTypes from './itemActiveTypes.js' import itemFilters from './itemFilters.js' import messages from './messages.js' +import offlineDashboards from './offlineDashboards.js' import passiveViewRegistered from './passiveViewRegistered.js' import printDashboard from './printDashboard.js' import selected from './selected.js' @@ -16,13 +15,12 @@ import slideshow from './slideshow.js' import visualizations from './visualizations.js' export default combineReducers({ - dashboards, selected, - dashboardsFilter, visualizations, messages, editDashboard, printDashboard, + offlineDashboards, itemFilters, dimensions, activeModalDimension, diff --git a/src/reducers/offlineDashboards.js b/src/reducers/offlineDashboards.js new file mode 100644 index 000000000..6a43333cd --- /dev/null +++ b/src/reducers/offlineDashboards.js @@ -0,0 +1,23 @@ +const initialState = [] + +export const SET_OFFLINE_DASHBOARDS = 'SET_OFFLINE_DASHBOARDS' +export const STAR_OFFLINE_DASHBOARD = 'STAR_OFFLINE_DASHBOARD' + +const offlineDashboards = (state = initialState, action) => { + switch (action.type) { + case SET_OFFLINE_DASHBOARDS: + return action.dashboards + + case STAR_OFFLINE_DASHBOARD: + return state.map((dashboard) => + dashboard.id === action.value.id + ? { ...dashboard, starred: action.value.starred } + : dashboard + ) + + default: + return state + } +} + +export default offlineDashboards diff --git a/src/reducers/selected.js b/src/reducers/selected.js index ca28b86e9..6de89a20a 100644 --- a/src/reducers/selected.js +++ b/src/reducers/selected.js @@ -2,6 +2,7 @@ import { createSelector } from 'reselect' 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 = { @@ -14,6 +15,7 @@ const SELECTED_PROPERTIES = { dashboardItems: [], layout: [], itemConfig: {}, + starred: false, embedded: undefined, } @@ -29,6 +31,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 } @@ -40,6 +48,8 @@ export const sGetSelected = (state) => state.selected export const sGetSelectedId = (state) => sGetSelected(state).id +export const sGetSelectedStarred = (state) => !!sGetSelected(state).starred + export const sGetSelectedIsEmbedded = (state) => !!sGetSelected(state).embedded export const msGetSelectedSupersetEmbedData = createSelector(