diff --git a/src/main/data-managers/Reportable.js b/src/main/data-managers/Reportable.js index 31eca31c4..0e304c99d 100644 --- a/src/main/data-managers/Reportable.js +++ b/src/main/data-managers/Reportable.js @@ -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; }; @@ -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); diff --git a/src/main/data-managers/__tests__/Reportable-test.js b/src/main/data-managers/__tests__/Reportable-test.js index f65d5dd5d..fcc213ab9 100644 --- a/src/main/data-managers/__tests__/Reportable-test.js +++ b/src/main/data-managers/__tests__/Reportable-test.js @@ -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: {} }, }); }); @@ -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, }); diff --git a/src/main/data-managers/__tests__/data/node-data-session.json b/src/main/data-managers/__tests__/data/node-data-session.json index 8243c9135..42db7c89c 100644 --- a/src/main/data-managers/__tests__/data/node-data-session.json +++ b/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", diff --git a/src/main/server/AdminService.js b/src/main/server/AdminService.js index 3b03aa36e..8eb1fcf10 100644 --- a/src/main/server/AdminService.js +++ b/src/main/server/AdminService.js @@ -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()); }); diff --git a/src/main/server/__tests__/AdminService-test.js b/src/main/server/__tests__/AdminService-test.js index 8264e5102..bcf0238fa 100644 --- a/src/main/server/__tests__/AdminService-test.js +++ b/src/main/server/__tests__/AdminService-test.js @@ -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); }); diff --git a/src/renderer/components/workspace/AnswerDistributionPanel.js b/src/renderer/components/workspace/AnswerDistributionPanel.js index f397cbd4b..4e98c3964 100644 --- a/src/renderer/components/workspace/AnswerDistributionPanel.js +++ b/src/renderer/components/workspace/AnswerDistributionPanel.js @@ -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) { @@ -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 (

- {variableDefinition.name} + {entityLabel(entityKey, entityType)}: {variableDefinition.name} {headerLabel(variableDefinition.type)} distribution @@ -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, }; diff --git a/src/renderer/containers/SettingsScreen.js b/src/renderer/containers/SettingsScreen.js index 19a8de6d4..a3b9c5648 100644 --- a/src/renderer/containers/SettingsScreen.js +++ b/src/renderer/containers/SettingsScreen.js @@ -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'; @@ -34,21 +36,22 @@ class SettingsScreen extends Component {

{ distributionVariables && - Object.entries(distributionVariables).map(([section, vars]) => ( - { - 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]) => ( + { + const newExcluded = vars.filter(v => !newValue.includes(v)); + setExcludedVariables(protocol.id, entity, section, newExcluded); + }, + }} + options={vars.map(v => ({ value: v, label: v }))} + /> + )))) }

@@ -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)); } diff --git a/src/renderer/containers/__tests__/SettingsScreen-test.js b/src/renderer/containers/__tests__/SettingsScreen-test.js index 5fcf6e3d0..d75402ae4 100644 --- a/src/renderer/containers/__tests__/SettingsScreen-test.js +++ b/src/renderer/containers/__tests__/SettingsScreen-test.js @@ -42,13 +42,17 @@ describe('', () => { }); 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'); diff --git a/src/renderer/containers/workspace/WorkspaceScreen.js b/src/renderer/containers/workspace/WorkspaceScreen.js index 5a620113f..e94321299 100644 --- a/src/renderer/containers/workspace/WorkspaceScreen.js +++ b/src/renderer/containers/workspace/WorkspaceScreen.js @@ -68,6 +68,8 @@ class WorkspaceScreen extends Component { ...answerDistributionCharts.map(chart => ( diff --git a/src/renderer/containers/workspace/__tests__/withAnswerDistributionCharts-test.js b/src/renderer/containers/workspace/__tests__/withAnswerDistributionCharts-test.js index 9adf5f3d5..ffc42190b 100644 --- a/src/renderer/containers/workspace/__tests__/withAnswerDistributionCharts-test.js +++ b/src/renderer/containers/workspace/__tests__/withAnswerDistributionCharts-test.js @@ -9,8 +9,12 @@ import { mockProtocol } from '../../../../../config/jest/setupTestEnv'; jest.mock('../../../utils/adminApiClient', () => { function MockApiClient() {} - MockApiClient.prototype.get = jest.fn().mockResolvedValue({ - buckets: { person: { distributionVariable: { 1: 4, 2: 5 } } }, + MockApiClient.prototype.post = jest.fn().mockResolvedValue({ + buckets: { + nodes: { person: { distributionVariable: { 1: 4, 2: 5 } } }, + edges: { friend: { catVariable: { 1: 3 } } }, + ego: { ordVariable: { 1: 2 } }, + }, }); return MockApiClient; }); @@ -36,6 +40,34 @@ jest.mock('../../../ducks/modules/protocols', () => ({ }, }, }, + edge: { + friend: { + variables: { + catVariable: { + name: '', + type: 'categorical', + options: [ + { label: 'a', value: 1 }, + { label: 'b', value: 2 }, + { label: 'c', value: 3 }, + ], + }, + }, + }, + }, + ego: { + variables: { + ordVariable: { + name: '', + type: 'ordinal', + options: [ + { label: 'a', value: 1 }, + { label: 'b', value: 2 }, + { label: 'c', value: 3 }, + ], + }, + }, + }, }), }, })); @@ -54,24 +86,24 @@ describe('AnswerDistributionPanels', () => { }); it('loads data', () => { - expect(mockApiClient.get).toHaveBeenCalled(); + expect(mockApiClient.post).toHaveBeenCalled(); }); it('loads data from API when totalSessionsCount changes', () => { wrapper.setProps({ totalSessionsCount: 1 }); - mockApiClient.get.mockClear(); - expect(mockApiClient.get).toHaveBeenCalledTimes(0); + mockApiClient.post.mockClear(); + expect(mockApiClient.post).toHaveBeenCalledTimes(0); wrapper.setProps({ totalSessionsCount: 2 }); - expect(mockApiClient.get).toHaveBeenCalledTimes(1); + expect(mockApiClient.post).toHaveBeenCalledTimes(1); }); it('loads data when protocolId changes', () => { // shallow to bypass mapStateToProps const wrapped = shallow( state)} />).dive(); wrapped.setProps({ protocolId: null }); - mockApiClient.get.mockClear(); + mockApiClient.post.mockClear(); wrapped.setProps({ protocolId: '2' }); - expect(mockApiClient.get).toHaveBeenCalled(); + expect(mockApiClient.post).toHaveBeenCalled(); }); describe('API handler', () => { @@ -81,17 +113,25 @@ describe('AnswerDistributionPanels', () => { }); it('renders one chart per variable', () => { - expect(wrapper.state('charts')).toHaveLength(1); + expect(wrapper.state('charts')).toHaveLength(3); }); it('sets correct data format', async () => { - const chart = wrapper.state('charts')[0]; - expect(chart.chartData).toContainEqual({ name: 'a', value: 4 }); - expect(chart.chartData).toContainEqual({ name: 'b', value: 5 }); + const nodeChart = wrapper.state('charts')[0]; + const edgeChart = wrapper.state('charts')[1]; + const egoChart = wrapper.state('charts')[2]; + expect(nodeChart.chartData).toContainEqual({ name: 'a', value: 4 }); + expect(nodeChart.chartData).toContainEqual({ name: 'b', value: 5 }); + expect(edgeChart.chartData).toContainEqual({ name: 'a', value: 3 }); + expect(egoChart.chartData).toContainEqual({ name: 'a', value: 2 }); }); it('sets zeros for missing values', async () => { expect(wrapper.state('charts')[0].chartData).toContainEqual({ name: 'c', value: 0 }); + expect(wrapper.state('charts')[1].chartData).toContainEqual({ name: 'c', value: 0 }); + expect(wrapper.state('charts')[1].chartData).toContainEqual({ name: 'b', value: 0 }); + expect(wrapper.state('charts')[2].chartData).toContainEqual({ name: 'c', value: 0 }); + expect(wrapper.state('charts')[2].chartData).toContainEqual({ name: 'b', value: 0 }); }); }); }); diff --git a/src/renderer/containers/workspace/withAnswerDistributionCharts.js b/src/renderer/containers/workspace/withAnswerDistributionCharts.js index f3b32f834..9c33ebf0f 100644 --- a/src/renderer/containers/workspace/withAnswerDistributionCharts.js +++ b/src/renderer/containers/workspace/withAnswerDistributionCharts.js @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { get } from 'lodash'; import AdminApiClient from '../../utils/adminApiClient'; import { selectors as protocolSelectors } from '../../ducks/modules/protocols'; @@ -27,14 +28,17 @@ const hasData = bucket => bucket && Object.keys(bucket).length > 0; * @param {Object} buckets The API response from `option_buckets` * @return {Array} chartDefinitions */ -const shapeBucketData = (transposedNodeCodebook, buckets, excludedChartVariables) => +const shapeBucketDataByType = ( + transposedNodeCodebook, buckets, excludedChartVariables, entityKey) => Object.entries(transposedNodeCodebook).reduce((acc, [entityType, { variables }]) => { - const excludedSectionVariables = excludedChartVariables[entityType] || []; - Object.entries(variables).forEach(([variableName, def]) => { + const excludedSectionVariables = (excludedChartVariables[entityKey] && + excludedChartVariables[entityKey][entityType]) || []; + Object.entries(variables || []).forEach(([variableName, def]) => { if (!isDistributionVariable(def) || excludedSectionVariables.includes(def.name)) { return; } - const data = buckets[entityType] && buckets[entityType][variableName]; + const dataPath = entityKey === 'ego' ? [variableName] : [entityType, variableName]; + const data = get(buckets, dataPath); const values = hasData(data) && def.options.map((option) => { // Option defs are usually in the format { label, value }, however: // - options may be strings or numerics instead of objects @@ -48,6 +52,7 @@ const shapeBucketData = (transposedNodeCodebook, buckets, excludedChartVariables }; }); acc.push({ + entityKey, entityType, variableType: def.type, variableDefinition: def, @@ -57,6 +62,15 @@ const shapeBucketData = (transposedNodeCodebook, buckets, excludedChartVariables return acc; }, []); +const shapeBucketData = (codebook, buckets, excludedChartVariables) => + Object.entries(buckets).reduce((acc, [entityKey]) => { + const entityCodebook = entityKey === 'ego' ? { ego: codebook[entityKey] } : codebook[entityKey]; + const bucketsByType = buckets[entityKey]; + const shapeData = shapeBucketDataByType( + entityCodebook, bucketsByType, excludedChartVariables, entityKey); + return acc.concat(shapeData); + }, []); + /** * HOC that provides chart definitions for the 'answer distribution' panels. * Charts are exposed as a prop so that panel components can be managed directly @@ -109,25 +123,39 @@ const withAnswerDistributionCharts = (WrappedComponent) => { const { excludedChartVariables, protocolId, - transposedCodebook: { node: nodeCodebook = {} }, + transposedCodebook: { + node: nodeCodebook = {}, + edge: edgeCodebook = {}, + ego: egoCodebook = {}, + }, } = this.props; + const nodeNames = Object.values(nodeCodebook).reduce((acc, nodeTypeDefinition) => ( + { + ...acc, + [nodeTypeDefinition.name]: Object.keys(nodeTypeDefinition.variables || {}), + } + ), {}); + const edgeNames = Object.values(edgeCodebook).reduce((acc, edgeTypeDefinition) => ( + { + ...acc, + [edgeTypeDefinition.name]: Object.keys(edgeTypeDefinition.variables || {}), + } + ), {}); + const egoNames = Object.keys(egoCodebook.variables || {}); + if (!protocolId) { return; } - const variableNames = Object.values(nodeCodebook).reduce((acc, nodeTypeDefinition) => { - acc.push(...Object.keys(nodeTypeDefinition.variables || {})); - return acc; - }, []); - + const variableNames = { nodes: nodeCodebook, edges: edgeCodebook, ego: egoCodebook }; const route = `/protocols/${this.props.protocolId}/reports/option_buckets`; - const query = { variableNames }; + const query = { nodeNames, edgeNames, egoNames }; - this.apiClient.get(route, query) + this.apiClient.post(route, query) .then(({ buckets }) => { this.setState({ - charts: shapeBucketData(nodeCodebook, buckets, excludedChartVariables), + charts: shapeBucketData(variableNames, buckets, excludedChartVariables), }); }); } diff --git a/src/renderer/ducks/modules/__tests__/excludedChartVariables-test.js b/src/renderer/ducks/modules/__tests__/excludedChartVariables-test.js index 4d0070389..9b91a10ef 100644 --- a/src/renderer/ducks/modules/__tests__/excludedChartVariables-test.js +++ b/src/renderer/ducks/modules/__tests__/excludedChartVariables-test.js @@ -18,6 +18,7 @@ describe('excludedChartVariables', () => { const action = { type: actionTypes.SET_EXCLUDED_VARIABLES, protocolId: 'protocol1', + entity: 'nodes', section: 'person', variables: ['someVar'], }; @@ -27,6 +28,7 @@ describe('excludedChartVariables', () => { it('returns current state if no protocol ID given', () => { const action = { type: actionTypes.SET_EXCLUDED_VARIABLES, + entity: 'nodes', section: 'person', variables: ['someVar'], }; @@ -37,21 +39,23 @@ describe('excludedChartVariables', () => { const action = { type: actionTypes.SET_EXCLUDED_VARIABLES, protocolId: '1', + entity: 'nodes', section: 'person', variables: ['someVar'], }; expect(reducer({}, action)).toEqual({ - 1: { person: ['someVar'] }, + 1: { nodes: { person: ['someVar'] } }, }); }); }); describe('setExcludedVariables action creator', () => { it('produces an exclude action', () => { - const action = actionCreators.setExcludedVariables('1', 'person', ['someVar']); + const action = actionCreators.setExcludedVariables('1', 'nodes', 'person', ['someVar']); expect(action).toEqual({ type: actionTypes.SET_EXCLUDED_VARIABLES, protocolId: '1', + entity: 'nodes', section: 'person', variables: ['someVar'], }); @@ -61,10 +65,10 @@ describe('excludedChartVariables', () => { describe('excludedVariablesForCurrentProtocol selector', () => { const { excludedVariablesForCurrentProtocol } = selectors; it('returns variables for the protocol', () => { - const excludedChartVariables = { 1: { person: ['someVar'] } }; + const excludedChartVariables = { 1: { nodes: { person: ['someVar'] } } }; const state = { protocols: [{ id: '1' }], excludedChartVariables }; const props = { match: { params: { id: '1' } } }; - expect(excludedVariablesForCurrentProtocol(state, props)).toEqual({ person: ['someVar'] }); + expect(excludedVariablesForCurrentProtocol(state, props)).toEqual({ nodes: { person: ['someVar'] } }); }); }); }); diff --git a/src/renderer/ducks/modules/__tests__/protocols-test.js b/src/renderer/ducks/modules/__tests__/protocols-test.js index ad824931c..b08c6f01c 100644 --- a/src/renderer/ducks/modules/__tests__/protocols-test.js +++ b/src/renderer/ducks/modules/__tests__/protocols-test.js @@ -128,13 +128,15 @@ describe('the protocols module', () => { }); describe('ordinalAndCategoricalVariables', () => { - it('returns node variable names sectioned by entity type', () => { + it('returns entity variable names sectioned by entity type', () => { const codebook = { node: { 'node-type-id': { name: 'person', variables: { 'var-id-1': { name: 'catVar', type: 'categorical' } } } }, + edge: { 'edge-type-id': { name: 'friend', variables: { 'var-id-2': { name: 'ordVar', type: 'ordinal' } } } }, + ego: { name: 'ego', variables: { 'var-id-3': { name: 'catVar', type: 'categorical' } } }, }; const state = { protocols: [{ id: '1', codebook }] }; const props = { match: { params: { id: '1' } } }; - expect(ordinalAndCategoricalVariables(state, props)).toEqual({ person: ['catVar'] }); + expect(ordinalAndCategoricalVariables(state, props)).toEqual({ nodes: { person: ['catVar'] }, edges: { friend: ['ordVar'] }, ego: { ego: ['catVar'] } }); }); it('ignores sections without these variables', () => { @@ -146,14 +148,15 @@ describe('the protocols module', () => { expect(ordinalAndCategoricalVariables(state, props)).not.toHaveProperty('venue'); }); - it('returns an empty object if node codebook unavailable', () => { + it('returns an empty object if entity codebook unavailable', () => { const state = { protocols: [{ id: '1', codebook: {} }] }; const props = { match: { params: { id: '1' } } }; - expect(ordinalAndCategoricalVariables(state, props)).toEqual({}); + expect(ordinalAndCategoricalVariables(state, props)).toEqual( + { nodes: {}, edges: {}, ego: {} }); }); it('returns an empty object if protocol unavailable', () => { - expect(ordinalAndCategoricalVariables({}, {})).toEqual({}); + expect(ordinalAndCategoricalVariables({}, {})).toEqual({ nodes: {}, edges: {}, ego: {} }); }); }); @@ -166,12 +169,20 @@ describe('the protocols module', () => { describe('transposedCodebook', () => { it('returns a modified codebook', () => { - const codebook = { node: { 'node-type-id': { name: 'person', variables: {} } } }; + const codebook = { + node: { 'node-type-id': { name: 'person', variables: {} } }, + edge: { 'edge-type-id': { name: 'friend', variables: {} } }, + ego: { name: 'ego', variables: { 'var-id-1': { name: 'ordVar', type: 'ordinal' } } }, + }; const state = { protocols: [{ id: '1', codebook }] }; const props = { match: { params: { id: '1' } } }; const transposed = transposedCodebook(state, props); expect(transposed).toHaveProperty('node'); expect(transposed.node).toHaveProperty('person'); + expect(transposed).toHaveProperty('edge'); + expect(transposed.edge).toHaveProperty('friend'); + expect(transposed).toHaveProperty('ego'); + expect(transposed.ego.variables).toHaveProperty('ordVar'); }); it('does not require edge variables', () => { diff --git a/src/renderer/ducks/modules/excludedChartVariables.js b/src/renderer/ducks/modules/excludedChartVariables.js index 2aa45cb71..badcbee65 100644 --- a/src/renderer/ducks/modules/excludedChartVariables.js +++ b/src/renderer/ducks/modules/excludedChartVariables.js @@ -11,7 +11,9 @@ import { selectors as protocolSelectors } from './protocols'; * ``` * { * [protocolId]: { - * [entityType]: [variableName1, variableName2] + * [entity]: { + * [entityType]: [variableName1, variableName2] + * } * } * } * ``` @@ -28,7 +30,12 @@ const reducer = (state = initialState, action = {}) => { if (!protocolId) { return state; } - const protocolState = { ...state[protocolId], [action.section]: action.variables }; + const protocolState = { ...state[protocolId], + [action.entity]: { + ...(state[protocolId] || {})[action.entity], + [action.section]: action.variables, + }, + }; return { ...state, [protocolId]: protocolState }; } default: @@ -38,13 +45,15 @@ const reducer = (state = initialState, action = {}) => { /** * @memberof module:excludedChartVariables + * @param {string} entity corresponds to an entity (e.g. node, edge, ego) * @param {string} section corresponds to an entity (node) type * @param {Array} variables list of variable names to exclude * @return {Object} excluded variable names, sectioned by entity type */ -const setExcludedVariables = (protocolId, section, variables) => ({ +const setExcludedVariables = (protocolId, entity, section, variables) => ({ type: SET_EXCLUDED_VARIABLES, protocolId, + entity, section, variables, }); diff --git a/src/renderer/ducks/modules/protocols.js b/src/renderer/ducks/modules/protocols.js index 613d168e2..56f86551b 100644 --- a/src/renderer/ducks/modules/protocols.js +++ b/src/renderer/ducks/modules/protocols.js @@ -1,7 +1,7 @@ import AdminApiClient from '../../utils/adminApiClient'; import viewModelMapper from '../../utils/baseViewModelMapper'; import { actionCreators as messageActionCreators } from './appMessages'; -import { transposedCodebookSection } from '../../../main/utils/formatters/network'; // TODO: move +import { transposedCodebook as networkTransposedCodebook } from '../../../main/utils/formatters/network'; // TODO: move const LOAD_PROTOCOLS = 'LOAD_PROTOCOLS'; const PROTOCOLS_LOADED = 'PROTOCOLS_LOADED'; @@ -56,15 +56,39 @@ const transposedCodebook = (state, props) => { } const codebook = protocol.codebook || {}; - return { - edge: transposedCodebookSection(codebook.edge), - node: transposedCodebookSection(codebook.node), - }; + return networkTransposedCodebook(codebook); }; const distributionVariableTypes = ['ordinal', 'categorical']; const isDistributionVariable = variable => distributionVariableTypes.includes(variable.type); +const getDistributionVariableNames = variables => ( + Object.entries(variables || {}).reduce((arr, [variableName, variable]) => { + if (isDistributionVariable(variable)) { + arr.push(variableName); + } + return arr; + }, []) +); + +const ordinalAndCategoricalVariablesByEntity = (entities) => { + if ((entities || {}).name === 'ego') { + const variableNames = getDistributionVariableNames(entities.variables); + if (variableNames.length) { + return { ego: variableNames }; + } + return {}; + } + + return Object.entries(entities || {}).reduce((acc, [entityType, { variables }]) => { + const variableNames = getDistributionVariableNames(variables); + if (variableNames.length) { + acc[entityType] = variableNames; + } + return acc; + }, {}); +}; + /** * @return {Object} all node ordinal & categorical variable names, sectioned by node type */ @@ -73,18 +97,10 @@ const ordinalAndCategoricalVariables = (state, props) => { if (!codebook) { return {}; } - return Object.entries(codebook.node || {}).reduce((acc, [entityType, { variables }]) => { - const variableNames = Object.entries(variables).reduce((arr, [variableName, variable]) => { - if (isDistributionVariable(variable)) { - arr.push(variableName); - } - return arr; - }, []); - if (variableNames.length) { - acc[entityType] = variableNames; - } - return acc; - }, {}); + + return { nodes: ordinalAndCategoricalVariablesByEntity(codebook.node), + edges: ordinalAndCategoricalVariablesByEntity(codebook.edge), + ego: ordinalAndCategoricalVariablesByEntity(codebook.ego) }; }; const protocolsHaveLoaded = state => state.protocols !== initialState;