diff --git a/packages/app/src/api/__tests__/userDataStore.spec.js b/packages/app/src/api/__tests__/userDataStore.spec.js new file mode 100644 index 0000000000..d1d5af8219 --- /dev/null +++ b/packages/app/src/api/__tests__/userDataStore.spec.js @@ -0,0 +1,113 @@ +import * as d2lib from 'd2'; +import * as userDataStore from '../userDataStore'; +import { + apiSave, + apiFetch, + hasNamespace, + getNamespace, + apiSaveAOInUserDataStore, + apiFetchAOFromUserDataStore, + NAMESPACE, + CURRENT_AO_KEY, +} from '../userDataStore'; + +let mockD2; +let mockNamespace; + +describe('api: user data store', () => { + beforeEach(() => { + mockNamespace = { + get: jest.fn(), + set: jest.fn(), + }; + mockD2 = { + currentUser: { + dataStore: { + has: jest.fn().mockResolvedValue(false), // false default value for test purposes + get: jest.fn().mockResolvedValue(mockNamespace), + create: jest.fn().mockResolvedValue(mockNamespace), + }, + }, + }; + d2lib.getInstance = () => Promise.resolve(mockD2); + }); + + describe('hasNamespace', () => { + it('uses result of "has" method of d2.currentUser.dataStore object', async () => { + const result = await hasNamespace(mockD2); + + expect(mockD2.currentUser.dataStore.has).toBeCalledTimes(1); + expect(mockD2.currentUser.dataStore.has).toBeCalledWith(NAMESPACE); + expect(result).toEqual(false); + }); + }); + + describe('getNamespace', () => { + it('retrieves and returns namespace if it exists', async () => { + const result = await getNamespace(mockD2, true); + + expect(mockD2.currentUser.dataStore.get).toBeCalledTimes(1); + expect(mockD2.currentUser.dataStore.create).toBeCalledTimes(0); + expect(result).toMatchObject(mockNamespace); + }); + + it('creates and returns namespace if it doesnt exist', async () => { + const result = await getNamespace(mockD2, false); + + expect(mockD2.currentUser.dataStore.get).toBeCalledTimes(0); + expect(mockD2.currentUser.dataStore.create).toBeCalledTimes(1); + expect(result).toMatchObject(mockNamespace); + }); + }); + + describe('apiSave', () => { + it('uses d2 namespace.set for saving data under given key', async () => { + const data = {}; + const key = 'someKey'; + + await apiSave(data, key, mockNamespace); + + expect(mockNamespace.set).toBeCalledTimes(1); + expect(mockNamespace.set).toBeCalledWith(key, data); + }); + }); + + describe('apiFetch', () => { + it('uses d2 namespace.get for retrieving data by given key', async () => { + const key = 'someKey'; + + await apiFetch(key, mockNamespace); + + expect(mockNamespace.get).toBeCalledTimes(1); + expect(mockNamespace.get).toBeCalledWith(key); + }); + }); + + describe('apiSaveAoInUserDataStore', () => { + beforeEach(() => { + userDataStore.getNamespace = () => Promise.resolve(mockNamespace); + }); + + it('uses default key unless specified', async () => { + const data = {}; + + await apiSaveAOInUserDataStore(data); + + expect(mockNamespace.set).toBeCalledTimes(1); + expect(mockNamespace.set).toBeCalledWith(CURRENT_AO_KEY, data); + }); + }); + + describe('apiFetchAOFromUserDataStore', () => { + beforeEach(() => { + userDataStore.getNamespace = () => Promise.resolve(mockNamespace); + }); + + it('uses default key unless specified', async () => { + await apiFetchAOFromUserDataStore(); + + expect(mockNamespace.get).toBeCalledTimes(1); + expect(mockNamespace.get).toBeCalledWith(CURRENT_AO_KEY); + }); + }); +}); diff --git a/packages/app/src/api/userDataStore.js b/packages/app/src/api/userDataStore.js new file mode 100644 index 0000000000..36b7992a86 --- /dev/null +++ b/packages/app/src/api/userDataStore.js @@ -0,0 +1,43 @@ +import { getInstance } from 'd2'; +import { onError } from './index'; + +export const NAMESPACE = 'analytics'; +export const CURRENT_AO_KEY = 'currentAnalyticalObject'; + +export const hasNamespace = async d2 => + await d2.currentUser.dataStore.has(NAMESPACE); + +export const getNamespace = async (d2, hasNamespace) => + hasNamespace + ? await d2.currentUser.dataStore.get(NAMESPACE) + : await d2.currentUser.dataStore.create(NAMESPACE); + +export const apiSave = async (data, key, namespace) => { + try { + const d2 = await getInstance(); + const ns = + namespace || (await getNamespace(d2, await hasNamespace(d2))); + + return ns.set(key, data); + } catch (error) { + return onError(error); + } +}; + +export const apiFetch = async (key, namespace) => { + try { + const d2 = await getInstance(); + const ns = + namespace || (await getNamespace(d2, await hasNamespace(d2))); + + return ns.get(key); + } catch (error) { + return onError(error); + } +}; + +export const apiSaveAOInUserDataStore = (current, key = CURRENT_AO_KEY) => + apiSave(current, key); + +export const apiFetchAOFromUserDataStore = (key = CURRENT_AO_KEY) => + apiFetch(key); diff --git a/packages/app/src/assets/GlobeIcon.js b/packages/app/src/assets/GlobeIcon.js new file mode 100644 index 0000000000..2b1bb53baa --- /dev/null +++ b/packages/app/src/assets/GlobeIcon.js @@ -0,0 +1,69 @@ +import React from 'react'; +import SvgIcon from '@material-ui/core/SvgIcon'; + +const GlobeIcon = ({ + style = { width: 24, height: 24, paddingRight: '8px' }, +}) => ( + + icon_chart_GIS + Created with Sketch. + + + + + + + + + + + + + + + + + + + +); + +export default GlobeIcon; diff --git a/packages/app/src/components/App.js b/packages/app/src/components/App.js index 579e5a01e4..f5dd222325 100644 --- a/packages/app/src/components/App.js +++ b/packages/app/src/components/App.js @@ -19,11 +19,16 @@ import * as fromActions from '../actions'; import history from '../modules/history'; import defaultMetadata from '../modules/metadata'; import { sGetUi } from '../reducers/ui'; +import { + apiFetchAOFromUserDataStore, + CURRENT_AO_KEY, +} from '../api/userDataStore'; import '@dhis2/ui/defaults/reset.css'; import './App.css'; import './scrollbar.css'; +import { getParentGraphMapFromVisualization } from '../modules/ui'; export class App extends Component { unlisten = null; @@ -60,13 +65,27 @@ export class App extends Component { const { store } = this.context; if (location.pathname.length > 1) { + // /currentAnalyticalObject // /${id}/ // /${id}/interpretation/${interpretationId} const pathParts = location.pathname.slice(1).split('/'); const id = pathParts[0]; const interpretationId = pathParts[2]; + const urlContainsCurrentAOKey = id === CURRENT_AO_KEY; - if (this.refetch(location)) { + if (urlContainsCurrentAOKey) { + const AO = await apiFetchAOFromUserDataStore(); + + this.props.addParentGraphMap( + getParentGraphMapFromVisualization(AO) + ); + + this.props.setVisualization(AO); + this.props.setUiFromVisualization(AO); + this.props.setCurrentFromUi(this.props.ui); + } + + if (!urlContainsCurrentAOKey && this.refetch(location)) { await store.dispatch( fromActions.tDoLoadVisualization( this.props.apiObjectName, @@ -106,6 +125,7 @@ export class App extends Component { ); this.loadVisualization(this.props.location); + this.unlisten = history.listen(location => { this.loadVisualization(location); }); @@ -115,7 +135,7 @@ export class App extends Component { e => e.key === 'Enter' && e.ctrlKey === true && - this.props.onKeyUp(this.props.ui) + this.props.setCurrentFromUi(this.props.ui) ); }; @@ -197,7 +217,16 @@ const mapStateToProps = state => { }; const mapDispatchToProps = dispatch => ({ - onKeyUp: ui => dispatch(fromActions.fromCurrent.acSetCurrentFromUi(ui)), + setCurrentFromUi: ui => + dispatch(fromActions.fromCurrent.acSetCurrentFromUi(ui)), + setVisualization: visualization => + dispatch( + fromActions.fromVisualization.acSetVisualization(visualization) + ), + setUiFromVisualization: visualization => + dispatch(fromActions.fromUi.acSetUiFromVisualization(visualization)), + addParentGraphMap: parentGraphMap => + dispatch(fromActions.fromUi.acAddParentGraphMap(parentGraphMap)), }); App.contextTypes = { diff --git a/packages/app/src/components/UpdateButton/UpdateButton.js b/packages/app/src/components/UpdateButton/UpdateButton.js index 414ccb0418..b7da9a4900 100644 --- a/packages/app/src/components/UpdateButton/UpdateButton.js +++ b/packages/app/src/components/UpdateButton/UpdateButton.js @@ -10,6 +10,7 @@ import { sGetCurrent } from '../../reducers/current'; import * as fromActions from '../../actions'; import history from '../../modules/history'; import styles from './styles/UpdateButton.style'; +import { CURRENT_AO_KEY } from '../../api/userDataStore'; const UpdateButton = ({ classes, @@ -25,10 +26,16 @@ const UpdateButton = ({ clearLoadError(); onUpdate(ui); + const urlContainsCurrentAOKey = + history.location.pathname === '/' + CURRENT_AO_KEY; + const pathWithoutInterpretation = current && current.id ? `/${current.id}` : '/'; - if (history.location.pathname !== pathWithoutInterpretation) { + if ( + !urlContainsCurrentAOKey && + history.location.pathname !== pathWithoutInterpretation + ) { history.push(pathWithoutInterpretation); } diff --git a/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeIcon.js b/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeIcon.js index 9adbc40215..161de125fd 100644 --- a/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeIcon.js +++ b/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeIcon.js @@ -12,6 +12,8 @@ import AreaIcon from '../../assets/AreaIcon'; import RadarIcon from '../../assets/RadarIcon'; import YearOverYearLineIcon from '../../assets/YearOverYearLineIcon'; import YearOverYearColumnIcon from '../../assets/YearOverYearColumnIcon'; +import GlobeIcon from '../../assets/GlobeIcon'; + import { COLUMN, STACKED_COLUMN, @@ -24,6 +26,7 @@ import { GAUGE, YEAR_OVER_YEAR_LINE, YEAR_OVER_YEAR_COLUMN, + OPEN_AS_MAP, chartTypeDisplayNames, } from '../../modules/chartTypes'; @@ -49,6 +52,8 @@ const VisualizationTypeIcon = ({ type = COLUMN, style }) => { return ; case YEAR_OVER_YEAR_COLUMN: return ; + case OPEN_AS_MAP: + return ; case COLUMN: default: return ; diff --git a/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeMenuItem.js b/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeMenuItem.js new file mode 100644 index 0000000000..211aa8d5b5 --- /dev/null +++ b/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeMenuItem.js @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import MenuItem from '@material-ui/core/MenuItem/MenuItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText/ListItemText'; + +import { chartTypeDisplayNames } from '../../modules/chartTypes'; +import VisualizationTypeIcon from './VisualizationTypeIcon'; + +const VisualizationTypeMenuItem = ({ + type, + visualizationType, + styles, + ...props +}) => ( + + + + + + +); + +VisualizationTypeMenuItem.propTypes = { + type: PropTypes.oneOf(Object.keys(chartTypeDisplayNames)), + visualizationType: PropTypes.oneOf(Object.keys(chartTypeDisplayNames)), + styles: PropTypes.object, +}; + +export default VisualizationTypeMenuItem; diff --git a/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeSelector.js b/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeSelector.js index f3824a4c1e..df31c4fa26 100644 --- a/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeSelector.js +++ b/packages/app/src/components/VisualizationTypeSelector/VisualizationTypeSelector.js @@ -1,39 +1,97 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; + +import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'; import Button from '@material-ui/core/Button'; import Menu from '@material-ui/core/Menu'; -import MenuItem from '@material-ui/core/MenuItem'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import ListItemText from '@material-ui/core/ListItemText'; -import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'; -import VisualizationTypeIcon from './VisualizationTypeIcon'; -import { chartTypeDisplayNames } from '../../modules/chartTypes'; -import { sGetUiType } from '../../reducers/ui'; + +import { + chartTypeDisplayNames, + isOpenAsType, + OPEN_AS_MAP, +} from '../../modules/chartTypes'; +import { prepareCurrentAnalyticalObject } from '../../modules/currentAnalyticalObject'; +import { sGetUi, sGetUiType } from '../../reducers/ui'; +import { sGetCurrent } from '../../reducers/current'; +import { sGetMetadata } from '../../reducers/metadata'; import { acSetUiType } from '../../actions/ui'; +import { + apiSaveAOInUserDataStore, + CURRENT_AO_KEY, +} from '../../api/userDataStore'; + +import VisualizationTypeMenuItem from './VisualizationTypeMenuItem'; +import VisualizationTypeIcon from './VisualizationTypeIcon'; import styles from './styles/VisualizationTypeSelector.style'; +export const MAPS_APP_URL = 'dhis-web-maps'; + +export const defaultState = { + anchorEl: null, +}; + export class VisualizationTypeSelector extends Component { - state = { - anchorEl: null, - }; + constructor(props, context) { + super(props); + + this.state = defaultState; + this.baseUrl = context.baseUrl; + this.chartTypes = this.getChartTypes(); + } handleButtonClick = event => { this.setState({ anchorEl: event.currentTarget }); }; - handleMenuItemClick = type => event => { + handleMenuItemClick = type => () => { this.props.onTypeSelect(type); this.handleClose(); }; + handleOpenAsMenuItemClick = type => () => { + if (type === OPEN_AS_MAP) { + this.handleOpenChartAsMapClick(); + } + }; + + handleOpenChartAsMapClick = async () => { + const currentAnalyticalObject = prepareCurrentAnalyticalObject( + this.props.current, + this.props.metadata, + this.props.ui + ); + + await apiSaveAOInUserDataStore(currentAnalyticalObject); + + window.location.href = `${ + this.baseUrl + }/${MAPS_APP_URL}?${CURRENT_AO_KEY}=true`; + }; + handleClose = () => { this.setState({ anchorEl: null }); }; + getChartTypes = () => { + return Object.keys(chartTypeDisplayNames).reduce( + (result, type) => { + const chartType = isOpenAsType(type) + ? 'openAsTypes' + : 'nativeTypes'; + + result[chartType].push(type); + + return result; + }, + { nativeTypes: [], openAsTypes: [] } + ); + }; + render() { const { anchorEl } = this.state; const { visualizationType } = this.props; + const { nativeTypes, openAsTypes } = this.chartTypes; return ( @@ -59,26 +117,26 @@ export class VisualizationTypeSelector extends Component { style: styles.menu, }} > - {Object.keys(chartTypeDisplayNames).map(type => ( - ( + - - - - - + /> + ))} +
+
+ {openAsTypes.map(type => ( + ))} @@ -88,10 +146,20 @@ export class VisualizationTypeSelector extends Component { VisualizationTypeSelector.propTypes = { visualizationType: PropTypes.oneOf(Object.keys(chartTypeDisplayNames)), + current: PropTypes.object, + metadata: PropTypes.object, + ui: PropTypes.object, +}; + +VisualizationTypeSelector.contextTypes = { + baseUrl: PropTypes.string, }; const mapStateToProps = state => ({ visualizationType: sGetUiType(state), + current: sGetCurrent(state), + metadata: sGetMetadata(state), + ui: sGetUi(state), }); const mapDispatchToProps = dispatch => ({ diff --git a/packages/app/src/components/VisualizationTypeSelector/__tests__/VisualizationTypeMenuItem.spec.js b/packages/app/src/components/VisualizationTypeSelector/__tests__/VisualizationTypeMenuItem.spec.js new file mode 100644 index 0000000000..64650d0168 --- /dev/null +++ b/packages/app/src/components/VisualizationTypeSelector/__tests__/VisualizationTypeMenuItem.spec.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import VisualizationTypeMenuItem from '../VisualizationTypeMenuItem'; +import MenuItem from '@material-ui/core/MenuItem'; +import VisualizationTypeIcon from '../VisualizationTypeIcon'; +import { COLUMN } from '../../../modules/chartTypes'; + +describe('VisualizationTypeMenuItem component ', () => { + let props; + let shallowElement; + + const element = () => { + if (!shallowElement) { + shallowElement = shallow(); + } + return shallowElement; + }; + + beforeEach(() => { + props = { + type: COLUMN, + visualizationType: COLUMN, + styles: {}, + }; + shallowElement = undefined; + }); + + it('renders MenuItem', () => { + expect( + element() + .find(MenuItem) + .first().length + ).toEqual(1); + }); + + it('renders VisualizationTypeIcon', () => { + expect( + element() + .find(VisualizationTypeIcon) + .first().length + ).toEqual(1); + }); +}); diff --git a/packages/app/src/components/VisualizationTypeSelector/styles/VisualizationTypeSelector.style.js b/packages/app/src/components/VisualizationTypeSelector/styles/VisualizationTypeSelector.style.js index 74488eff78..cf32ebfb30 100644 --- a/packages/app/src/components/VisualizationTypeSelector/styles/VisualizationTypeSelector.style.js +++ b/packages/app/src/components/VisualizationTypeSelector/styles/VisualizationTypeSelector.style.js @@ -17,6 +17,11 @@ export default { maxWidth: '600px', padding: 0, }, + menuDivider: { + border: 'none', + borderBottom: '1px solid', + borderColor: colors.greyLight, + }, menuItem: { height: 120, width: 126, @@ -30,6 +35,7 @@ export default { }, listItemIcon: { margin: 0, + marginBottom: 15, }, listItemSvg: { width: 48, @@ -42,4 +48,7 @@ export default { whiteSpace: 'normal', textAlign: 'center', }, + clearFix: { + clear: 'both', + }, }; diff --git a/packages/app/src/components/__tests__/App.spec.js b/packages/app/src/components/__tests__/App.spec.js index 1390d2bc04..24a48306f2 100644 --- a/packages/app/src/components/__tests__/App.spec.js +++ b/packages/app/src/components/__tests__/App.spec.js @@ -7,6 +7,9 @@ import * as actions from '../../actions/'; import history from '../../modules/history'; import { getStubContext } from '../../../../../config/testsContext'; +import { CURRENT_AO_KEY } from '../../api/userDataStore'; +import * as userDataStore from '../../api/userDataStore'; +import * as ui from '../../modules/ui'; jest.mock('../Visualization/Visualization', () => () =>
); @@ -48,11 +51,18 @@ describe('App', () => { }, keyAnalysisRelativePeriod: 'LAST_12_MONTHS', }, + + addParentGraphMap: jest.fn(), + setVisualization: jest.fn(), + setUiFromVisualization: jest.fn(), + setCurrentFromUi: jest.fn(), }; shallowApp = undefined; actions.tDoLoadVisualization = jest.fn(); actions.clearVisualization = jest.fn(); + userDataStore.apiFetchAOFromUserDataStore = jest.fn(); + ui.getParentGraphMapFromVisualization = jest.fn(); }); afterEach(() => { @@ -127,6 +137,25 @@ describe('App', () => { }); }); + it('loads AO from user data store if id equals to "currentAnalyticalObject"', done => { + props.location.pathname = '/' + CURRENT_AO_KEY; + + app(); + + setTimeout(() => { + expect( + userDataStore.apiFetchAOFromUserDataStore + ).toBeCalledTimes(1); + + expect(props.addParentGraphMap).toBeCalledTimes(1); + expect(props.setCurrentFromUi).toBeCalledTimes(1); + expect(props.setVisualization).toBeCalledTimes(1); + expect(props.setUiFromVisualization).toBeCalledTimes(1); + + done(); + }); + }); + describe('interpretation id in pathname', () => { beforeEach(() => { props.location.pathname = `/applejack/interpretation/xyz123`; diff --git a/packages/app/src/modules/__tests__/currentAnalyticalObject.spec.js b/packages/app/src/modules/__tests__/currentAnalyticalObject.spec.js new file mode 100644 index 0000000000..176ff9a014 --- /dev/null +++ b/packages/app/src/modules/__tests__/currentAnalyticalObject.spec.js @@ -0,0 +1,243 @@ +import { + appendDimensionItemNamesToAnalyticalObject, + appendPathsToOrgUnits, + prepareCurrentAnalyticalObject, + removeUnnecessaryAttributesFromAnalyticalObject, +} from '../currentAnalyticalObject'; + +describe('currentAnalyticalObject', () => { + let mockCurrent; + let mockMetadata; + let mockUi; + + beforeEach(() => { + mockCurrent = { + id: 'SOME_ID', + name: 'Analytical object typical name', + displayName: 'Analytical object typical name', + key: 'value', + columns: [ + { + dimension: 'dx', + items: [ + { + id: 'Uvn6LCg7dVU', + }, + ], + }, + ], + filters: [ + { + dimension: 'ou', + items: [ + { + id: 'qhqAxPSTUXp', + }, + { + id: 'Vth0fbpFcsO', + }, + ], + }, + ], + rows: [ + { + dimension: 'pe', + items: [ + { + id: '2017', + }, + ], + }, + ], + }; + mockMetadata = { + Uvn6LCg7dVU: { name: 'ANC 1 Coverage' }, + qhqAxPSTUXp: { name: 'Koinadugu' }, + Vth0fbpFcsO: { name: 'Kono' }, + 2017: { name: '2017' }, + }; + mockUi = { + parentGraphMap: { + qhqAxPSTUXp: 'ImspTQPwCqd', + Vth0fbpFcsO: 'ImspTQPwCqd', + }, + }; + }); + + describe('appendPathsToOrgUnits', () => { + it('appends org unit paths to current analytical object', () => { + const expected = { + ...mockCurrent, + filters: [ + { + dimension: 'ou', + items: [ + { + id: 'qhqAxPSTUXp', + path: 'ImspTQPwCqd', + }, + { + id: 'Vth0fbpFcsO', + path: 'ImspTQPwCqd', + }, + ], + }, + ], + }; + + expect(appendPathsToOrgUnits(mockCurrent, mockUi)).toEqual( + expected + ); + }); + + it('handles visualization without "ou" dimension', () => { + const mockCurrentWithoutOuDimension = { + ...mockCurrent, + rows: [], + columns: [ + { + dimension: 'J5jldMd8OHv', + items: [{ id: 'uYxK4wmcPqA' }, { id: 'RXL3lPSK8oG' }], + }, + ], + filters: [ + { + dimension: 'pe', + items: [{ id: 'LAST_YEAR' }], + }, + { + dimension: 'dx', + items: [{ id: 'hfdmMSPBgLG' }], + }, + ], + }; + + expect( + appendPathsToOrgUnits(mockCurrentWithoutOuDimension, mockUi) + ).toEqual(mockCurrentWithoutOuDimension); + }); + }); + + describe('appendDimensionItemNamesToAnalyticalObject', () => { + const testDimensionItemNamesAreUndefined = dimension => { + dimension.items.forEach(item => { + expect(item.name).toBeUndefined(); + }); + }; + + const testDimensionItemNamesAreNotUndefined = dimension => { + dimension.items.forEach(item => { + expect(item.name).not.toBeUndefined(); + expect(item.name).toEqual(mockMetadata[item.id].name); + }); + }; + + it('appends dimension item names to analytical object', () => { + mockCurrent.columns.forEach(testDimensionItemNamesAreUndefined); + mockCurrent.filters.forEach(testDimensionItemNamesAreUndefined); + mockCurrent.rows.forEach(testDimensionItemNamesAreUndefined); + + const processed = appendDimensionItemNamesToAnalyticalObject( + mockCurrent, + mockMetadata + ); + + processed.columns.forEach(testDimensionItemNamesAreNotUndefined); + processed.filters.forEach(testDimensionItemNamesAreNotUndefined); + processed.rows.forEach(testDimensionItemNamesAreNotUndefined); + }); + + it('passes dimension items without names as they are without skipping them', () => { + mockCurrent.columns.forEach(testDimensionItemNamesAreUndefined); + mockCurrent.filters.forEach(testDimensionItemNamesAreUndefined); + mockCurrent.rows.forEach(testDimensionItemNamesAreUndefined); + + mockMetadata = {}; + + const processed = appendDimensionItemNamesToAnalyticalObject( + mockCurrent, + mockMetadata + ); + + expect(processed).toEqual(mockCurrent); + }); + }); + + describe('removeUnnecessaryAttributesFromAnalyticalObject', () => { + it('removes unnecessary attributes', () => { + const unprocessed = { + id: 'SOME_ID', + name: 'Analytical object typical name', + displayName: 'Analytical object typical name', + key: 'value', + }; + const processed = removeUnnecessaryAttributesFromAnalyticalObject( + unprocessed + ); + const keysToRemove = ['id', 'name', 'displayName']; + + keysToRemove.forEach(key => { + expect(unprocessed[key]).not.toBeUndefined(); + }); + + keysToRemove.forEach(key => { + expect(processed[key]).toBeUndefined(); + }); + }); + }); + + describe('prepareCurrentAnalyticalObject', () => { + it('appends org unit paths, dimension item names and removes attributes and ', () => { + const expected = { + key: 'value', + columns: [ + { + dimension: 'dx', + items: [ + { + id: 'Uvn6LCg7dVU', + name: 'ANC 1 Coverage', + }, + ], + }, + ], + filters: [ + { + dimension: 'ou', + items: [ + { + id: 'qhqAxPSTUXp', + name: 'Koinadugu', + path: 'ImspTQPwCqd', + }, + { + id: 'Vth0fbpFcsO', + name: 'Kono', + path: 'ImspTQPwCqd', + }, + ], + }, + ], + rows: [ + { + dimension: 'pe', + items: [ + { + id: '2017', + name: '2017', + }, + ], + }, + ], + }; + + const result = prepareCurrentAnalyticalObject( + mockCurrent, + mockMetadata, + mockUi + ); + + expect(expected).toEqual(result); + }); + }); +}); diff --git a/packages/app/src/modules/__tests__/ui.spec.js b/packages/app/src/modules/__tests__/ui.spec.js new file mode 100644 index 0000000000..81843e6a97 --- /dev/null +++ b/packages/app/src/modules/__tests__/ui.spec.js @@ -0,0 +1,111 @@ +import { getParentGraphMapFromVisualization } from '../ui'; + +describe('ui', () => { + let mockCurrent; + let mockUi; + + beforeEach(() => { + mockCurrent = { + id: 'SOME_ID', + name: 'Analytical object typical name', + displayName: 'Analytical object typical name', + key: 'value', + columns: [ + { + dimension: 'dx', + items: [ + { + id: 'Uvn6LCg7dVU', + }, + ], + }, + ], + filters: [ + { + dimension: 'ou', + items: [ + { + id: 'qhqAxPSTUXp', + }, + { + id: 'Vth0fbpFcsO', + }, + ], + }, + ], + rows: [ + { + dimension: 'pe', + items: [ + { + id: '2017', + }, + ], + }, + ], + }; + mockUi = { + parentGraphMap: { + qhqAxPSTUXp: 'ImspTQPwCqd', + Vth0fbpFcsO: 'ImspTQPwCqd', + }, + }; + }); + + describe('getParentGraphMapFromVisualization', () => { + it('generates parent graph map from visualization', () => { + const { parentGraphMap } = mockUi; + const mockCurrentWirhOrgUnitPaths = { + ...mockCurrent, + filters: [ + { + dimension: 'ou', + items: [ + { + id: 'qhqAxPSTUXp', + path: 'ImspTQPwCqd', + }, + { + id: 'Vth0fbpFcsO', + path: 'ImspTQPwCqd', + }, + ], + }, + ], + }; + + expect( + getParentGraphMapFromVisualization(mockCurrentWirhOrgUnitPaths) + ).toEqual(parentGraphMap); + }); + + it('handles visualization without "ou" dimension', () => { + const mockCurrentWithoutOuDimension = { + ...mockCurrent, + rows: [], + columns: [ + { + dimension: 'J5jldMd8OHv', + items: [{ id: 'uYxK4wmcPqA' }, { id: 'RXL3lPSK8oG' }], + }, + ], + filters: [ + { + dimension: 'pe', + items: [{ id: 'LAST_YEAR' }], + }, + { + dimension: 'dx', + items: [{ id: 'hfdmMSPBgLG' }], + }, + ], + }; + + expect( + getParentGraphMapFromVisualization( + mockCurrentWithoutOuDimension + ) + ).toEqual({}); + }); + }); +}); diff --git a/packages/app/src/modules/chartTypes.js b/packages/app/src/modules/chartTypes.js index 57e1c654ef..cb2f00ef25 100644 --- a/packages/app/src/modules/chartTypes.js +++ b/packages/app/src/modules/chartTypes.js @@ -12,6 +12,7 @@ export const GAUGE = 'GAUGE'; export const BUBBLE = 'BUBBLE'; export const YEAR_OVER_YEAR_LINE = 'YEAR_OVER_YEAR_LINE'; export const YEAR_OVER_YEAR_COLUMN = 'YEAR_OVER_YEAR_COLUMN'; +export const OPEN_AS_MAP = 'OPEN_AS_MAP'; export const chartTypeDisplayNames = { [COLUMN]: i18n.t('Column'), @@ -25,10 +26,14 @@ export const chartTypeDisplayNames = { [GAUGE]: i18n.t('Gauge'), [YEAR_OVER_YEAR_LINE]: i18n.t('Year over year (line)'), [YEAR_OVER_YEAR_COLUMN]: i18n.t('Year over year (column)'), + [OPEN_AS_MAP]: i18n.t('Open as: Map'), }; const stackedTypes = [STACKED_COLUMN, STACKED_BAR, AREA]; const yearOverYearTypes = [YEAR_OVER_YEAR_LINE, YEAR_OVER_YEAR_COLUMN]; +const openAsTypes = [OPEN_AS_MAP]; +export const defaultChartType = COLUMN; export const isStacked = type => stackedTypes.includes(type); export const isYearOverYear = type => yearOverYearTypes.includes(type); +export const isOpenAsType = type => openAsTypes.includes(type); diff --git a/packages/app/src/modules/currentAnalyticalObject.js b/packages/app/src/modules/currentAnalyticalObject.js new file mode 100644 index 0000000000..2c92b4d4ab --- /dev/null +++ b/packages/app/src/modules/currentAnalyticalObject.js @@ -0,0 +1,62 @@ +import { FIXED_DIMENSIONS } from './fixedDimensions'; +import { getDimensionIdsByAxis, getInverseLayout } from './layout'; + +export const appendPathsToOrgUnits = (current, ui) => { + const ouId = FIXED_DIMENSIONS.ou.id; + const dimensionIdsByAxis = getDimensionIdsByAxis(current); + const inverseLayout = getInverseLayout(dimensionIdsByAxis); + const ouAxis = inverseLayout[ouId]; + const { parentGraphMap } = ui; + + if (!ouAxis) { + return current; + } + + return { + ...current, + [ouAxis]: current[ouAxis].map(dimension => ({ + ...dimension, + items: dimension.items.map(item => ({ + ...item, + path: parentGraphMap[item.id], + })), + })), + }; +}; + +export const removeUnnecessaryAttributesFromAnalyticalObject = current => ({ + ...current, + id: undefined, + name: undefined, + displayName: undefined, +}); + +export const appendDimensionItemNamesToAnalyticalObject = ( + current, + metadata +) => { + const appendNames = dimension => ({ + ...dimension, + items: dimension.items.map(item => ({ + ...item, + name: metadata[item.id] ? metadata[item.id].name : undefined, + })), + }); + + return { + ...current, + columns: current.columns.map(appendNames), + filters: current.filters.map(appendNames), + rows: current.rows.map(appendNames), + }; +}; + +export const prepareCurrentAnalyticalObject = (current, metadata, ui) => { + let result; + + result = removeUnnecessaryAttributesFromAnalyticalObject(current); + result = appendDimensionItemNamesToAnalyticalObject(result, metadata); + result = appendPathsToOrgUnits(result, ui); + + return result; +}; diff --git a/packages/app/src/modules/layout.js b/packages/app/src/modules/layout.js index 0a6f4bc4ba..f01c4fb53b 100644 --- a/packages/app/src/modules/layout.js +++ b/packages/app/src/modules/layout.js @@ -66,6 +66,7 @@ export const getFilteredLayout = (layout, excludedIds) => { export const getItemIdsByDimension = visualization => { const dimensions = getAllDimensions(visualization); + return dimensions.reduce( (map, dim) => ({ ...map, diff --git a/packages/app/src/modules/ui.js b/packages/app/src/modules/ui.js index 200d1531cf..d9844944f0 100644 --- a/packages/app/src/modules/ui.js +++ b/packages/app/src/modules/ui.js @@ -3,8 +3,13 @@ import { YEAR_OVER_YEAR_COLUMN, PIE, GAUGE, + defaultChartType, } from './chartTypes'; -import { getDimensionIdsByAxis, getItemIdsByDimension } from './layout'; +import { + getDimensionIdsByAxis, + getInverseLayout, + getItemIdsByDimension, +} from './layout'; import { FIXED_DIMENSIONS } from './fixedDimensions'; import { isYearOverYear } from './chartTypes'; import { getOptionsFromVisualization } from './options'; @@ -16,11 +21,14 @@ const peId = FIXED_DIMENSIONS.pe.id; // Transform from backend model to store.ui format export const getUiFromVisualization = (vis, currentState = {}) => ({ ...currentState, - type: vis.type, + type: vis.type || defaultChartType, options: getOptionsFromVisualization(vis), layout: getDimensionIdsByAxis(vis), itemsByDimension: getItemIdsByDimension(vis), - parentGraphMap: vis.parentGraphMap || currentState.parentGraphMap, + parentGraphMap: + vis.parentGraphMap || + getParentGraphMapFromVisualization(vis) || + currentState.parentGraphMap, yearOverYearSeries: isYearOverYear(vis.type) && vis[BASE_FIELD_YEARLY_SERIES] ? vis[BASE_FIELD_YEARLY_SERIES] @@ -64,3 +72,27 @@ export const getAdaptedUiByType = ui => { return ui; } }; + +export const getParentGraphMapFromVisualization = vis => { + const ouId = FIXED_DIMENSIONS.ou.id; + const dimensionIdsByAxis = getDimensionIdsByAxis(vis); + const inverseLayout = getInverseLayout(dimensionIdsByAxis); + const ouAxis = inverseLayout[ouId]; + + if (!ouAxis) { + return {}; + } + + const parentGraphMap = {}; + const ouDimension = vis[ouAxis].find( + dimension => dimension.dimension === ouId + ); + + ouDimension.items + .filter(orgUnit => orgUnit.path) + .forEach(orgUnit => { + parentGraphMap[orgUnit.id] = orgUnit.path; + }); + + return parentGraphMap; +};