Skip to content
This repository has been archived by the owner on Apr 4, 2024. It is now read-only.

Commit

Permalink
Merge pull request #255 from codaco/feature/ego-charts
Browse files Browse the repository at this point in the history
Feature/ego charts
  • Loading branch information
jthrilly committed Aug 21, 2019
2 parents 0fb591f + 288f284 commit 78d5bc8
Show file tree
Hide file tree
Showing 15 changed files with 348 additions and 112 deletions.
62 changes: 47 additions & 15 deletions src/main/data-managers/Reportable.js
Expand Up @@ -30,6 +30,7 @@ const flatten = shallowArrays => [].concat(...shallowArrays);
const entityKey = (entityName) => {
if (entityName === 'node') return 'nodes';
if (entityName === 'edge') return 'edges';
if (entityName === 'ego') return 'ego';
return null;
};

Expand Down Expand Up @@ -190,26 +191,57 @@ const Reportable = Super => class extends Super {
*
* @memberOf Reportable.prototype
*/
optionValueBuckets(protocolId, variableNames, entityName = 'node') {
optionValueBuckets(protocolId, nodeNames, edgeNames, egoNames) {
let allBuckets;
return this.optionValueBucketsByEntity(protocolId, nodeNames, 'node')
.then((nodeBuckets) => {
allBuckets = { ...allBuckets, nodes: nodeBuckets };
return this.optionValueBucketsByEntity(protocolId, edgeNames, 'edge');
})
.then((edgeBuckets) => {
allBuckets = { ...allBuckets, edges: edgeBuckets };
return this.optionValueBucketsByEntity(protocolId, egoNames, 'ego');
})
.then(egoBuckets => ({ ...allBuckets, ego: egoBuckets }));
}

optionValueBucketsByEntity(protocolId, variableNames, entityName) {
return new Promise((resolve, reject) => {
const key = entityKey(entityName);
this.db.find({ protocolId, [`data.${key}`]: { $exists: true } }, resolveOrReject((docs) => {
const entities = flatten(docs.map(doc => doc.data[key]));
const buckets = entities.reduce((acc, entity) => {
acc[entity.type] = acc[entity.type] || {};
variableNames.forEach((variableName) => {
acc[entity.type][variableName] = acc[entity.type][variableName] || {};
const optionValue = entity[attributesProperty][variableName];
if (optionValue !== undefined) {
// Categorical values are expressed as arrays of multiple options
const optionValues = (optionValue instanceof Array) ? optionValue : [optionValue];
const counts = acc[entity.type][variableName];
optionValues.forEach((value) => {
counts[value] = counts[value] || 0;
counts[value] += 1;
});
}
});
if (entityName === 'ego') {
acc = acc || {};
(variableNames || []).forEach((variableName) => {
acc[variableName] = acc[variableName] || {};
const optionValue = entity[attributesProperty][variableName];
if (optionValue !== undefined) {
// Categorical values are expressed as arrays of multiple options
const optionValues = (optionValue instanceof Array) ? optionValue : [optionValue];
const counts = acc[variableName];
optionValues.forEach((value) => {
counts[value] = counts[value] || 0;
counts[value] += 1;
});
}
});
} else {
acc[entity.type] = acc[entity.type] || {};
(variableNames[entity.type] || []).forEach((variableName) => {
acc[entity.type][variableName] = acc[entity.type][variableName] || {};
const optionValue = entity[attributesProperty][variableName];
if (optionValue !== undefined) {
// Categorical values are expressed as arrays of multiple options
const optionValues = (optionValue instanceof Array) ? optionValue : [optionValue];
const counts = acc[entity.type][variableName];
optionValues.forEach((value) => {
counts[value] = counts[value] || 0;
counts[value] += 1;
});
}
});
}
return acc;
}, {});
resolve(buckets);
Expand Down
60 changes: 48 additions & 12 deletions src/main/data-managers/__tests__/Reportable-test.js
Expand Up @@ -109,27 +109,62 @@ describe('Reportable', () => {
});
});

describe('with node variables', () => {
describe('with variables', () => {
beforeAll(() => { mockData = NodeDataSession; });
it('summarizes an ordinal variable', async () => {
await expect(reportDB.optionValueBuckets(mockData.protocolId, ['frequencyOrdinal'])).resolves.toMatchObject({
person: {
frequencyOrdinal: {
1: 1,
2: 1,
await expect(reportDB.optionValueBuckets(mockData.protocolId, { person: ['frequencyOrdinal'] }, {}, [])).resolves.toMatchObject({
nodes: {
person: {
frequencyOrdinal: {
1: 1,
2: 1,
},
},
},
edges: {},
ego: {},
});
});

it('summarizes a categorical variable', async () => {
await expect(reportDB.optionValueBuckets(mockData.protocolId, ['preferenceCategorical'])).resolves.toMatchObject({
person: {
preferenceCategorical: {
a: 2,
b: 1,
await expect(reportDB.optionValueBuckets(mockData.protocolId, { person: ['preferenceCategorical'] }, {}, [])).resolves.toMatchObject({
nodes: {
person: {
preferenceCategorical: {
a: 2,
b: 1,
},
},
},
edges: {},
ego: {},
});
});

it('summarizes an edge variable', async () => {
await expect(reportDB.optionValueBuckets(mockData.protocolId, {}, { friend: ['catVariable'] }, [])).resolves.toMatchObject({
edges: {
friend: {
catVariable: {
1: 1,
2: 1,
},
},
},
nodes: {},
ego: {},
});
});

it('summarizes an ego variable', async () => {
await expect(reportDB.optionValueBuckets(mockData.protocolId, {}, {}, ['ordVariable'])).resolves.toMatchObject({
ego: {
ordVariable: {
2: 2,
},
},
edges: { friend: {} },
nodes: { person: {} },
});
});

Expand All @@ -144,7 +179,8 @@ describe('Reportable', () => {
const result = await reportDB.entityTimeSeries(mockData.protocolId);
expect(result.entities).toContainEqual({
time: expect.any(Number),
edge: 0,
edge: 2,
edge_friend: 2,
node: 2,
node_person: 2,
});
Expand Down
33 changes: 32 additions & 1 deletion src/main/data-managers/__tests__/data/node-data-session.json
@@ -1,6 +1,37 @@
{
"data": {
"edges": [],
"ego": [
{
"_uid": "31980bde-1cc1-4681-b482-8a6ede07c2c8",
"type": "ego",
"attributes": {
"ordVariable": 2
}
},
{
"_uid": "35fb6341-91a0-4674-b615-039280c9c212",
"type": "person",
"attributes": {
"ordVariable": 2
}
}
],
"edges": [
{
"_uid": "61980bde-1cc1-4681-b482-8a6ede07c2c8",
"type": "friend",
"attributes": {
"catVariable": 1
}
},
{
"_uid": "f5fb6341-91a0-4674-b615-039280c9c212",
"type": "friend",
"attributes": {
"catVariable": 2
}
}
],
"nodes": [
{
"_uid": "91980bde-1cc1-4681-b482-8a6ede07c2c8",
Expand Down
18 changes: 13 additions & 5 deletions src/main/server/AdminService.js
Expand Up @@ -193,11 +193,19 @@ class AdminService {
.then(() => next());
});

// ?variableNames=var1,var2&entityName=node
// "buckets": { "person": { "var1": { "val1": 0, "val2": 0 }, "var2": {} } }
api.get('/protocols/:id/reports/option_buckets', (req, res, next) => {
const { variableNames = '', entityName = 'node' } = req.query;
this.reportDb.optionValueBuckets(req.params.id, variableNames.split(','), entityName)
// nodeNames: { type1: [var1, var2], type2: [var1, var3] },
// edgeNames: { type1: [var1] }, egoNames: [var1, var2],
// egoNames: [var1, var2]
// "buckets": {
// nodes: { "person": { "var1": { "val1": 0, "val2": 0 }, "var2": {} } }
// edges: { "friend": { "var1": { "val1": 0, "val2": 0} } }
// ego: { "var1": { "val1": 0 } }
// }
// We use post here, instead of get, because the data is more complicated than just a list
// of variables, it's organized by entity and type.
api.post('/protocols/:id/reports/option_buckets', (req, res, next) => {
const { nodeNames = '', edgeNames = '', egoNames = '' } = req.body;
this.reportDb.optionValueBuckets(req.params.id, nodeNames, edgeNames, egoNames)
.then(buckets => res.send({ status: 'ok', buckets }))
.then(() => next());
});
Expand Down
2 changes: 1 addition & 1 deletion src/main/server/__tests__/AdminService-test.js
Expand Up @@ -294,7 +294,7 @@ describe('the AdminService', () => {

it('fetches bucketed categorical/ordinal data', async () => {
const endpoint = makeUrl('protocols/1/reports/option_buckets', apiBase);
const res = await jsonClient.get(endpoint);
const res = await jsonClient.post(endpoint, { nodeNames: '', edgeNames: '', egoNames: '' });
expect(res.json.status).toBe('ok');
expect(res.json.buckets).toMatchObject(bucketsResult);
});
Expand Down
15 changes: 13 additions & 2 deletions src/renderer/components/workspace/AnswerDistributionPanel.js
Expand Up @@ -10,6 +10,13 @@ const chartComponent = variableType => ((variableType === 'categorical') ? PieCh

const headerLabel = variableType => ((variableType === 'categorical') ? 'Categorical' : 'Ordinal');

export const entityLabel = (entityKey, entityType) => {
if (entityKey === 'nodes') return `Node (${entityType})`;
if (entityKey === 'edges') return `Edge (${entityType})`;
if (entityKey === 'ego') return 'Ego';
return null;
};

const content = (chartData, variableType) => {
const Chart = chartComponent(variableType);
if (chartData.length) {
Expand All @@ -24,12 +31,12 @@ const content = (chartData, variableType) => {
*/
class AnswerDistributionPanel extends PureComponent {
render() {
const { chartData, variableDefinition } = this.props;
const { chartData, entityKey, entityType, variableDefinition } = this.props;
const totalObservations = sumValues(chartData);
return (
<div className="dashboard__panel dashboard__panel--chart">
<h4 className="dashboard__header-text">
{variableDefinition.name}
{entityLabel(entityKey, entityType)}: {variableDefinition.name}
<small className="dashboard__header-subtext">
{headerLabel(variableDefinition.type)} distribution
</small>
Expand All @@ -50,10 +57,14 @@ class AnswerDistributionPanel extends PureComponent {

AnswerDistributionPanel.defaultProps = {
chartData: [],
entityType: '',
entityKey: '',
};

AnswerDistributionPanel.propTypes = {
chartData: PropTypes.array,
entityKey: PropTypes.string,
entityType: PropTypes.string,
variableDefinition: Types.variableDefinition.isRequired,
};

Expand Down
40 changes: 22 additions & 18 deletions src/renderer/containers/SettingsScreen.js
Expand Up @@ -4,6 +4,8 @@ import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Redirect, withRouter } from 'react-router-dom';
import { compose } from 'recompose';

import { entityLabel } from '../components/workspace/AnswerDistributionPanel';
import { actionCreators as dialogActions } from '../ducks/modules/dialogs';
import Types from '../types';
import CheckboxGroup from '../ui/components/Fields/CheckboxGroup';
Expand Down Expand Up @@ -34,21 +36,22 @@ class SettingsScreen extends Component {
</p>
{
distributionVariables &&
Object.entries(distributionVariables).map(([section, vars]) => (
<CheckboxGroup
key={section}
className="settings__checkbox-group"
label={`Type: ${section}`}
input={{
value: this.includedChartVariablesForSection(section),
onChange: (newValue) => {
const newExcluded = vars.filter(v => !newValue.includes(v));
setExcludedVariables(protocol.id, section, newExcluded);
},
}}
options={vars.map(v => ({ value: v, label: v }))}
/>
))
Object.entries(distributionVariables).map(([entity, varsWithTypes]) => (
Object.entries(varsWithTypes).map(([section, vars]) => (
<CheckboxGroup
key={section}
className="settings__checkbox-group"
label={entityLabel(entity, section)}
input={{
value: this.includedChartVariablesForSection(entity, section),
onChange: (newValue) => {
const newExcluded = vars.filter(v => !newValue.includes(v));
setExcludedVariables(protocol.id, entity, section, newExcluded);
},
}}
options={vars.map(v => ({ value: v, label: v }))}
/>
))))
}
</div>
</div>
Expand All @@ -69,10 +72,11 @@ class SettingsScreen extends Component {
}
}

includedChartVariablesForSection = (section) => {
includedChartVariablesForSection = (entity, section) => {
const { excludedChartVariables, distributionVariables } = this.props;
const excludeSection = excludedChartVariables[section];
return distributionVariables[section].filter(
const excludeSection = excludedChartVariables[entity] &&
excludedChartVariables[entity][section];
return distributionVariables[entity][section].filter(
variable => !excludeSection || !excludeSection.includes(variable));
}

Expand Down
10 changes: 7 additions & 3 deletions src/renderer/containers/__tests__/SettingsScreen-test.js
Expand Up @@ -42,13 +42,17 @@ describe('<SettingsScreen />', () => {
});

it('renders checkboxes for chart variable selection', () => {
const distributionVariables = { person: ['catVar'] };
const distributionVariables = {
nodes: { person: ['catVar'] },
edges: { friend: ['catVar'] },
ego: { ego: ['catVar'] },
};
subject.setProps({ protocol: mockProtocol, distributionVariables });
expect(subject.find('CheckboxGroup')).toHaveLength(1);
expect(subject.find('CheckboxGroup')).toHaveLength(3);
});

it('updates excluded variables from checkbox input', () => {
const distributionVariables = { person: ['catVar'] };
const distributionVariables = { nodes: { person: ['catVar'] } };
subject.setProps({ protocol: mockProtocol, distributionVariables });
expect(setExcludedVariables).not.toHaveBeenCalled();
const checkboxes = subject.find('CheckboxGroup');
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/containers/workspace/WorkspaceScreen.js
Expand Up @@ -68,6 +68,8 @@ class WorkspaceScreen extends Component {
...answerDistributionCharts.map(chart => (
<AnswerDistributionPanel
key={`AnswerDistributionPanel-${chart.variableType}-${chart.entityType}-${chart.variableDefinition.name}`}
entityKey={chart.entityKey}
entityType={chart.entityType}
chartData={chart.chartData}
variableDefinition={chart.variableDefinition}
/>
Expand Down

0 comments on commit 78d5bc8

Please sign in to comment.