Skip to content

Commit

Permalink
Feature/open chart as map [DHIS2-5987] (#213)
Browse files Browse the repository at this point in the history
This enables "Open as: Map" chart type in DV app.

User Data Store (UDS) has been chosen for data sharing across DV and Maps apps.
More details about user data store in [docs](https://docs.dhis2.org/master/en/developer/html/webapi_user_data_store.

Chart config format
After some discussion decision was made to remove some attributes from analytical object (AO) before putting it into user data store:
- id
- name
- displayName

Reason: if user decides to open existing chart/AO in another app (e.g. maps), then we need to allow sharing only chart configuration (excluding name and interpretations).

How it works

Open as map use case
- Prepares AO format
  - Removes `id`, `name`, `displayName` attributes from object
  - Appends `path` attributes to org unit dimension items
  - Appends dimension item names
- Saves transformed AO in user data store with `namespace=analytics` and `key=currentAnalyticalObject`
- Redirects to maps app with URL flag `currentAnalyticalObject=true`

Open visualization from map use case
- On app start checks if routing id equals to `currentAnalyticalObject` (assume yes for this use case)
- Fetches visualization config from user data store using same namespace and key (`analytics` and `currentAnalyticalObject` correspondingly)
- Generates `parentGraphMap` and puts it in `ui` redux object (required for org units tree functioning)
- Calls redux `acSetVisualization` action with fetched AO as argument
- Calls redux `acSetUiFromVisualization` action with fetched AO as argument
- Calls redux `acSetCurrentFromUi` action with updated `current` redux store object
  • Loading branch information
neeilya committed Feb 27, 2019
1 parent 13ab746 commit 2c1eae1
Show file tree
Hide file tree
Showing 17 changed files with 945 additions and 36 deletions.
113 changes: 113 additions & 0 deletions packages/app/src/api/__tests__/userDataStore.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
43 changes: 43 additions & 0 deletions packages/app/src/api/userDataStore.js
Original file line number Diff line number Diff line change
@@ -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);
69 changes: 69 additions & 0 deletions packages/app/src/assets/GlobeIcon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';
import SvgIcon from '@material-ui/core/SvgIcon';

const GlobeIcon = ({
style = { width: 24, height: 24, paddingRight: '8px' },
}) => (
<SvgIcon viewBox="0 0 48 48" style={style}>
<title>icon_chart_GIS</title>
<desc>Created with Sketch.</desc>
<defs>
<rect id="path-1" x="0" y="0" width="48" height="48" />
</defs>
<g
id="Symbols"
stroke="none"
strokeWidth="1"
fill="none"
fillRule="evenodd"
>
<g id="Icon/48x48/chart_GIS">
<g id="icon_chart_GIS">
<mask id="mask-2" fill="white">
<use xlinkHref="#path-1" />
</mask>
<g id="Bounds" />
<circle
id="Oval-4"
stroke="#1976D2"
strokeWidth="2"
mask="url(#mask-2)"
cx="24"
cy="24"
r="23"
/>
<polyline
id="Path-6"
stroke="#1976D2"
strokeWidth="2"
mask="url(#mask-2)"
points="1 21 4 24 8 26 9 24 6 19 11 18 18 12 14 9 16 6 15 3"
/>
<polyline
id="Path-7"
stroke="#1976D2"
strokeWidth="2"
mask="url(#mask-2)"
points="47 25 45 21 43 19 40 18 37 18 34 17 32 18 30 23 33 27 37 27 38 30 38 38 38.5 42"
/>
<polyline
id="Path-5"
stroke="#1976D2"
strokeWidth="2"
mask="url(#mask-2)"
points="38 6 37 7 34 6 32 8 34 10 33 12 33 15 37 14 39 15 43 12"
/>
<polyline
id="Path-8"
stroke="#1976D2"
strokeWidth="2"
mask="url(#mask-2)"
points="18 46 16 41 15 36 13 34 10 31 11 28 14 26 18 27 20 29 23 30 25 32 25 36 23 40 21 47"
/>
</g>
</g>
</g>
</SvgIcon>
);

export default GlobeIcon;
35 changes: 32 additions & 3 deletions packages/app/src/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -106,6 +125,7 @@ export class App extends Component {
);

this.loadVisualization(this.props.location);

this.unlisten = history.listen(location => {
this.loadVisualization(location);
});
Expand All @@ -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)
);
};

Expand Down Expand Up @@ -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 = {
Expand Down
9 changes: 8 additions & 1 deletion packages/app/src/components/UpdateButton/UpdateButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,6 +26,7 @@ import {
GAUGE,
YEAR_OVER_YEAR_LINE,
YEAR_OVER_YEAR_COLUMN,
OPEN_AS_MAP,
chartTypeDisplayNames,
} from '../../modules/chartTypes';

Expand All @@ -49,6 +52,8 @@ const VisualizationTypeIcon = ({ type = COLUMN, style }) => {
return <YearOverYearLineIcon style={style} />;
case YEAR_OVER_YEAR_COLUMN:
return <YearOverYearColumnIcon style={style} />;
case OPEN_AS_MAP:
return <GlobeIcon style={style} />;
case COLUMN:
default:
return <ColumnIcon style={style} />;
Expand Down
Loading

0 comments on commit 2c1eae1

Please sign in to comment.