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;
+};