- );
-};
+ );
+ }
+}
AnswerDistributionPanel.defaultProps = {
chartData: [],
diff --git a/src/renderer/components/PanelItem.js b/src/renderer/components/workspace/PanelItem.js
similarity index 100%
rename from src/renderer/components/PanelItem.js
rename to src/renderer/components/workspace/PanelItem.js
diff --git a/src/renderer/components/ProtocolPanel.js b/src/renderer/components/workspace/ProtocolPanel.js
similarity index 95%
rename from src/renderer/components/ProtocolPanel.js
rename to src/renderer/components/workspace/ProtocolPanel.js
index cb24c6e03..2a8d03841 100644
--- a/src/renderer/components/ProtocolPanel.js
+++ b/src/renderer/components/workspace/ProtocolPanel.js
@@ -1,6 +1,6 @@
import React from 'react';
-import Types from '../types';
+import Types from '../../types';
import PanelItem from './PanelItem';
const ProtocolPanel = ({ protocol }) => (
diff --git a/src/renderer/components/ServerPanel.js b/src/renderer/components/workspace/ServerPanel.js
similarity index 95%
rename from src/renderer/components/ServerPanel.js
rename to src/renderer/components/workspace/ServerPanel.js
index 731864163..d34162e80 100644
--- a/src/renderer/components/ServerPanel.js
+++ b/src/renderer/components/workspace/ServerPanel.js
@@ -1,7 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
-import { PanelItem } from '../components';
-import withApiClient from './withApiClient';
+import { PanelItem } from '../../components';
+import withApiClient from '../withApiClient';
class ServerPanel extends Component {
constructor() {
diff --git a/src/renderer/components/SessionHistoryPanel.js b/src/renderer/components/workspace/SessionHistoryPanel.js
similarity index 93%
rename from src/renderer/components/SessionHistoryPanel.js
rename to src/renderer/components/workspace/SessionHistoryPanel.js
index ff8aa947f..34316f2b1 100644
--- a/src/renderer/components/SessionHistoryPanel.js
+++ b/src/renderer/components/workspace/SessionHistoryPanel.js
@@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
-import BarChart from './charts/BarChart';
-import EmptyData from './charts/EmptyData';
+import BarChart from '../charts/BarChart';
+import EmptyData from '../charts/EmptyData';
const buildChartContent = (sessions) => {
if (!sessions.length) {
diff --git a/src/renderer/components/SessionPanel.js b/src/renderer/components/workspace/SessionPanel.js
similarity index 94%
rename from src/renderer/components/SessionPanel.js
rename to src/renderer/components/workspace/SessionPanel.js
index ac80d6401..8eb357c3c 100644
--- a/src/renderer/components/SessionPanel.js
+++ b/src/renderer/components/workspace/SessionPanel.js
@@ -1,8 +1,8 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
-import { DismissButton, ScrollingPanelItem } from '../components';
-import { formatDate } from '../utils/formatters';
+import { DismissButton, ScrollingPanelItem } from '../../components';
+import { formatDate } from '../../utils/formatters';
const emptyContent = (
Interviews you import from Network Canvas will appear here.
);
diff --git a/src/renderer/components/workspace/SortablePanel.js b/src/renderer/components/workspace/SortablePanel.js
new file mode 100644
index 000000000..476e08a8b
--- /dev/null
+++ b/src/renderer/components/workspace/SortablePanel.js
@@ -0,0 +1,23 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { SortableElement, SortableHandle } from 'react-sortable-hoc';
+
+const DragHandle = SortableHandle(() =>
);
+
+class Panel extends PureComponent {
+ render() {
+ const { children } = this.props;
+ return (
+
+ );
+ }
+}
+
+Panel.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export default SortableElement(Panel);
diff --git a/src/renderer/components/workspace/SortablePanels.js b/src/renderer/components/workspace/SortablePanels.js
new file mode 100644
index 000000000..cca993f0e
--- /dev/null
+++ b/src/renderer/components/workspace/SortablePanels.js
@@ -0,0 +1,42 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { SortableContainer } from 'react-sortable-hoc';
+
+import SortablePanel from './SortablePanel';
+
+/**
+ * Render a collection of sortable workspace panels.
+ * Note: SortableContainer must render a containing DOM element (
) around the
+ * SortableElement collection.
+ */
+class Panels extends PureComponent {
+ render() {
+ const { className, panels } = this.props;
+ return (
+
+ {
+ panels.map((panel, index) => (
+
+ { panel }
+
+ ))
+ }
+
+ );
+ }
+}
+
+Panels.defaultProps = {
+ className: '',
+};
+
+Panels.propTypes = {
+ className: PropTypes.string,
+ panels: PropTypes.arrayOf(PropTypes.node).isRequired,
+};
+
+export default SortableContainer(Panels, { withRef: true });
diff --git a/src/renderer/components/__tests__/AnswerDistributionPanel-test.js b/src/renderer/components/workspace/__tests__/AnswerDistributionPanel-test.js
similarity index 100%
rename from src/renderer/components/__tests__/AnswerDistributionPanel-test.js
rename to src/renderer/components/workspace/__tests__/AnswerDistributionPanel-test.js
diff --git a/src/renderer/components/__tests__/PanelItem-test.js b/src/renderer/components/workspace/__tests__/PanelItem-test.js
similarity index 100%
rename from src/renderer/components/__tests__/PanelItem-test.js
rename to src/renderer/components/workspace/__tests__/PanelItem-test.js
diff --git a/src/renderer/components/__tests__/ProtocolPanel-test.js b/src/renderer/components/workspace/__tests__/ProtocolPanel-test.js
similarity index 82%
rename from src/renderer/components/__tests__/ProtocolPanel-test.js
rename to src/renderer/components/workspace/__tests__/ProtocolPanel-test.js
index ebffe0999..de7e48373 100644
--- a/src/renderer/components/__tests__/ProtocolPanel-test.js
+++ b/src/renderer/components/workspace/__tests__/ProtocolPanel-test.js
@@ -1,7 +1,7 @@
/* eslint-env jest */
import React from 'react';
import { shallow } from 'enzyme';
-import { mockProtocol } from '../../../../config/jest/setupTestEnv';
+import { mockProtocol } from '../../../../../config/jest/setupTestEnv';
import ProtocolPanel from '../ProtocolPanel';
diff --git a/src/renderer/components/__tests__/ServerPanel-test.js b/src/renderer/components/workspace/__tests__/ServerPanel-test.js
similarity index 100%
rename from src/renderer/components/__tests__/ServerPanel-test.js
rename to src/renderer/components/workspace/__tests__/ServerPanel-test.js
diff --git a/src/renderer/components/__tests__/SessionHistoryPanel-test.js b/src/renderer/components/workspace/__tests__/SessionHistoryPanel-test.js
similarity index 100%
rename from src/renderer/components/__tests__/SessionHistoryPanel-test.js
rename to src/renderer/components/workspace/__tests__/SessionHistoryPanel-test.js
diff --git a/src/renderer/components/__tests__/SessionPanel-test.js b/src/renderer/components/workspace/__tests__/SessionPanel-test.js
similarity index 100%
rename from src/renderer/components/__tests__/SessionPanel-test.js
rename to src/renderer/components/workspace/__tests__/SessionPanel-test.js
diff --git a/src/renderer/components/workspace/__tests__/SortablePanel-test.js b/src/renderer/components/workspace/__tests__/SortablePanel-test.js
new file mode 100644
index 000000000..9a7ca49e1
--- /dev/null
+++ b/src/renderer/components/workspace/__tests__/SortablePanel-test.js
@@ -0,0 +1,24 @@
+/* eslint-env jest */
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+
+import SortablePanel from '../SortablePanel';
+
+jest.mock('react-sortable-hoc', () => ({
+ SortableElement: jest.fn(component => component),
+ SortableHandle: jest.fn(component => component),
+}));
+
+describe('SortablePanel', () => {
+ it('renders provided children', () => {
+ const children =
mock
;
+ const sortable = shallow(
{children});
+ expect(sortable.contains(children)).toBe(true);
+ });
+
+ it('renders a drag handle', () => {
+ const children =
mock
;
+ const sortable = mount(
{children});
+ expect(sortable.find('.sortable__handle')).toHaveLength(1);
+ });
+});
diff --git a/src/renderer/components/workspace/__tests__/SortablePanels-test.js b/src/renderer/components/workspace/__tests__/SortablePanels-test.js
new file mode 100644
index 000000000..2c3c06fda
--- /dev/null
+++ b/src/renderer/components/workspace/__tests__/SortablePanels-test.js
@@ -0,0 +1,26 @@
+/* eslint-env jest */
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import SortablePanels from '../SortablePanels';
+
+jest.mock('react-sortable-hoc', () => ({
+ SortableContainer: jest.fn(component => component),
+ SortableElement: jest.fn(component => component),
+ SortableHandle: jest.fn(component => component),
+}));
+
+describe('SortablePanel', () => {
+ it('renders children mapped from a "panels" prop', () => {
+ const panels = [
mock
];
+ const subject = shallow(
);
+ expect(subject.contains(panels[0])).toBe(true);
+ });
+
+ it('defines an index for sorting', () => {
+ const panels = [
mock
,
mock
];
+ const subject = shallow(
).find('Panel');
+ expect(subject.get(0).props.index).toBe(0);
+ expect(subject.get(1).props.index).toBe(1);
+ });
+});
diff --git a/src/renderer/containers/AnswerDistributionPanels.js b/src/renderer/containers/AnswerDistributionPanels.js
deleted file mode 100644
index 8ad5a6536..000000000
--- a/src/renderer/containers/AnswerDistributionPanels.js
+++ /dev/null
@@ -1,140 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-
-import withApiClient from '../components/withApiClient';
-import AnswerDistributionPanel from '../components/AnswerDistributionPanel';
-import { selectors as protocolSelectors } from '../ducks/modules/protocols';
-import { selectors as variableSelectors } from '../ducks/modules/excludedChartVariables';
-import Types from '../types';
-
-const { isDistributionVariable } = protocolSelectors;
-const { excludedVariablesForCurrentProtocol } = variableSelectors;
-
-const hasData = bucket => bucket && Object.keys(bucket).length > 0;
-
-/**
- * Translates the node variables and the data available for ordinal & categorical variables
- * into a series of chart definitions.
- *
- * One chart definition is produced for each ordinal & categorical variable. If session data
- * contains any answers for that variable, then the entire range of answers is returned (including
- * `0` values). If no data is available for a variable, then the chart contains an empty chartData
- * value, so the child component can easily render an empty data view.
- *
- * @private
- *
- * @param {Object} transposedNodeRegistry `transposedRegistry.node`, with transposed names
- * @param {Object} buckets The API response from `option_buckets`
- * @return {Array} chartDefinitions
- */
-const shapeBucketData = (transposedNodeRegistry, buckets, excludedChartVariables) =>
- Object.entries(transposedNodeRegistry).reduce((acc, [entityType, { variables }]) => {
- const excludedSectionVariables = excludedChartVariables[entityType] || [];
- Object.entries(variables).forEach(([variableName, def]) => {
- if (!isDistributionVariable(def) || excludedSectionVariables.includes(def.name)) {
- return;
- }
- const data = buckets[entityType] && buckets[entityType][variableName];
- 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
- const isOptionObject = option && typeof option === 'object';
- // - label is optional, in which case `value` is used as the label
- const name = isOptionObject ? (option.label || option.value) : option;
- const dataKey = (isOptionObject ? option.value : option).toString();
- return {
- name,
- value: data[dataKey] || 0,
- };
- });
- acc.push({
- entityType,
- variableType: def.type,
- variableDefinition: def,
- chartData: values || [],
- });
- });
- return acc;
- }, []);
-
-/**
- * Renders a collection of ordinal & categorical distribution panels
- */
-class AnswerDistributionPanels extends Component {
- constructor(props) {
- super(props);
- this.state = {
- charts: [],
- };
- }
-
- componentDidMount() {
- this.loadData();
- }
-
- componentDidUpdate(prevProps) {
- const prevCount = prevProps.sessionCount;
- const newCount = this.props.sessionCount;
- // When mounted (on each workspace load), sessionCount is null.
- // Only reload data when session count changes (i.e., a session was
- // imported or deleted while on this workspace).
- if (newCount !== null && prevCount !== null && newCount !== prevCount) {
- this.loadData();
- }
- }
-
- loadData() {
- const { excludedChartVariables, transposedRegistry: { node: nodeRegistry = {} } } = this.props;
- const variableNames = Object.values(nodeRegistry).reduce((acc, nodeTypeDefinition) => {
- acc.push(...Object.keys(nodeTypeDefinition.variables || {}));
- return acc;
- }, []);
-
- const route = `/protocols/${this.props.protocolId}/reports/option_buckets`;
- const query = { variableNames };
-
- this.props.apiClient.get(route, query)
- .then(({ buckets }) => {
- this.setState({
- charts: shapeBucketData(nodeRegistry, buckets, excludedChartVariables),
- });
- });
- }
-
- render() {
- return this.state.charts.map(chart => (
-
- ));
- }
-}
-
-const mapStateToProps = (state, ownProps) => ({
- excludedChartVariables: excludedVariablesForCurrentProtocol(state, ownProps),
-});
-
-AnswerDistributionPanels.defaultProps = {
- apiClient: null,
- excludedChartVariables: {},
- sessionCount: null,
-};
-
-AnswerDistributionPanels.propTypes = {
- apiClient: PropTypes.object,
- excludedChartVariables: PropTypes.object,
- protocolId: PropTypes.string.isRequired,
- sessionCount: PropTypes.number,
- transposedRegistry: Types.variableRegistry.isRequired,
-};
-
-const UnconnectedAnswerDistributionPanels = withApiClient(AnswerDistributionPanels);
-
-export default connect(mapStateToProps)(UnconnectedAnswerDistributionPanels);
-
-export {
- UnconnectedAnswerDistributionPanels,
-};
diff --git a/src/renderer/containers/AppRoutes.js b/src/renderer/containers/AppRoutes.js
index 28e1938c4..a4427f6f9 100644
--- a/src/renderer/containers/AppRoutes.js
+++ b/src/renderer/containers/AppRoutes.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Component } from 'react';
import {
Route,
Redirect,
@@ -8,22 +8,36 @@ import {
import { PairDevice, OverviewScreen, SettingsScreen, ExportScreen, WorkspaceScreen } from './';
import { TabBar } from '../components';
-const AppRoutes = () => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
+class AppRoutes extends Component {
+ constructor(props) {
+ super(props);
+ this.scrollContainerRef = React.createRef();
+ }
+
+ render() {
+ // WorkspaceScreen takes a scrollContainerRef prop for sortable panels. This allows
+ // the main content area to scroll when a panel is being dragged.
+ const workspaceRenderer = props =>
+
;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
export default AppRoutes;
diff --git a/src/renderer/containers/WorkspaceScreen.js b/src/renderer/containers/WorkspaceScreen.js
deleted file mode 100644
index e9d5d68e1..000000000
--- a/src/renderer/containers/WorkspaceScreen.js
+++ /dev/null
@@ -1,169 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import logger from 'electron-log';
-import { connect } from 'react-redux';
-import { ipcRenderer } from 'electron';
-
-import Types from '../types';
-import InterviewStatsPanel from './InterviewStatsPanel';
-import ProtocolCountsPanel from './ProtocolCountsPanel';
-import AnswerDistributionPanels from './AnswerDistributionPanels';
-import EntityTimeSeriesPanel from './EntityTimeSeriesPanel';
-import withApiClient from '../components/withApiClient';
-import viewModelMapper from '../utils/baseViewModelMapper';
-import { Spinner } from '../ui';
-import { selectors } from '../ducks/modules/protocols';
-import {
- ProtocolPanel,
- ServerPanel,
- SessionHistoryPanel,
- SessionPanel,
-} from '../components';
-
-class WorkspaceScreen extends Component {
- static getDerivedStateFromProps(props, state) {
- if (props.protocol && props.protocol.id !== state.prevProtocolId) {
- // Protocol has changed; reset data to trigger new load
- return {
- prevProtocolId: props.protocol.id,
- sessions: null,
- totalSessionsCount: null,
- };
- }
- return null;
- }
-
- constructor(props) {
- super(props);
- this.state = {
- sessions: null,
- totalSessionsCount: null,
- };
- }
-
- componentDidMount() {
- this.loadSessions();
- ipcRenderer.on('SESSIONS_IMPORTED', this.onSessionsImported);
- }
-
- componentDidUpdate() {
- if (!this.state.sessions) {
- this.loadSessions();
- }
- }
-
- componentWillUnmount() {
- if (this.loadPromise) { this.loadPromise.cancelled = true; }
- ipcRenderer.removeListener('SESSIONS_IMPORTED', this.onSessionsImported);
- }
-
- onSessionsImported = () => this.loadSessions()
-
- get sessionsEndpoint() {
- const id = this.props.protocol.id;
- return id && `/protocols/${id}/sessions`;
- }
-
- sessionEndpoint(sessionId) {
- const base = this.sessionsEndpoint;
- return base && `${base}/${sessionId}`;
- }
-
- loadSessions() {
- const { apiClient, protocol } = this.props;
- if (!protocol || !apiClient || this.loadPromise) {
- return;
- }
- this.loadPromise = apiClient.get(this.sessionsEndpoint)
- .then((resp) => {
- if (!this.loadPromise.cancelled) {
- const sessions = resp.sessions.map(viewModelMapper);
- this.setState({ sessions, totalSessionsCount: resp.totalSessions });
- }
- })
- .catch((err) => {
- if (!this.loadPromise.cancelled) {
- logger.error(err);
- this.setState({ sessions: [] });
- }
- })
- .then(() => { this.loadPromise = null; });
- }
-
- deleteAllSessions() {
- this.props.apiClient.delete(this.sessionsEndpoint)
- .then(() => this.loadSessions());
- }
-
- deleteSession(sessionId) {
- const { apiClient } = this.props;
- apiClient.delete(this.sessionEndpoint(sessionId))
- .then(() => this.loadSessions());
- }
-
- render() {
- const { protocol, transposedRegistry } = this.props;
- const { sessions, totalSessionsCount } = this.state;
- if (!protocol || !sessions) {
- return
;
- }
-
- return (
-
-
-
-
-
-
- {
- sessions &&
-
- }
-
this.deleteAllSessions()}
- deleteSession={sessionId => this.deleteSession(sessionId)}
- />
- {
- sessions &&
- }
- {
- sessions &&
-
- }
-
-
-
-
- );
- }
-}
-
-const mapStateToProps = (state, ownProps) => ({
- protocol: selectors.currentProtocol(state, ownProps),
- transposedRegistry: selectors.transposedRegistry(state, ownProps),
-});
-
-WorkspaceScreen.defaultProps = {
- protocol: null,
-};
-
-WorkspaceScreen.propTypes = {
- apiClient: PropTypes.object.isRequired,
- protocol: Types.protocol,
- transposedRegistry: Types.variableRegistry.isRequired,
-};
-
-export default connect(mapStateToProps)(withApiClient(WorkspaceScreen));
-
-export { WorkspaceScreen as UnconnectedWorkspaceScreen };
diff --git a/src/renderer/containers/__tests__/AnswerDistributionPanels-test.js b/src/renderer/containers/__tests__/AnswerDistributionPanels-test.js
deleted file mode 100644
index 8c3616c20..000000000
--- a/src/renderer/containers/__tests__/AnswerDistributionPanels-test.js
+++ /dev/null
@@ -1,84 +0,0 @@
-/* eslint-env jest */
-import React from 'react';
-import { mount, shallow } from 'enzyme';
-import { UnconnectedAnswerDistributionPanels as AnswerDistributionPanels } from '../AnswerDistributionPanels';
-
-import AdminApiClient from '../../utils/adminApiClient';
-
-jest.mock('recharts');
-jest.mock('../../utils/adminApiClient', () => {
- function MockApiClient() {}
- MockApiClient.prototype.get = jest.fn().mockResolvedValue({
- buckets: { person: { distributionVariable: { 1: 4, 2: 5 } } },
- });
- return MockApiClient;
-});
-
-describe('AnswerDistributionPanels', () => {
- let props;
- let subject;
- let mockApiClient;
- // let variableType;
-
- // beforeAll(() => {
- // variableType = 'categorical';
- // });
-
- beforeEach(() => {
- props = {
- protocolId: '1',
- transposedRegistry: {
- node: {
- person: {
- variables: {
- distributionVariable: {
- label: '',
- type: 'ordinal',
- options: [
- { label: 'a', value: 1 },
- { label: 'b', value: 2 },
- { label: 'c', value: 3 },
- ],
- },
- },
- },
- },
- },
- };
- mockApiClient = new AdminApiClient();
- subject = mount(
);
- });
-
- it('loads data', () => {
- expect(mockApiClient.get).toHaveBeenCalled();
- });
-
- it('loads data from API when sessionCount changes', () => {
- subject.setProps({ sessionCount: 1 });
- mockApiClient.get.mockClear();
- expect(mockApiClient.get).toHaveBeenCalledTimes(0);
- subject.setProps({ sessionCount: 2 });
- expect(mockApiClient.get).toHaveBeenCalledTimes(1);
- });
-
- describe('API handler', () => {
- beforeEach(async () => {
- subject = shallow(
).dive();
- await subject.instance().loadData();
- });
-
- it('renders one chart per variable', () => {
- expect(subject.state('charts')).toHaveLength(1);
- });
-
- it('sets correct data format', async () => {
- const chart = subject.state('charts')[0];
- expect(chart.chartData).toContainEqual({ name: 'a', value: 4 });
- expect(chart.chartData).toContainEqual({ name: 'b', value: 5 });
- });
-
- it('sets zeros for missing values', async () => {
- expect(subject.state('charts')[0].chartData).toContainEqual({ name: 'c', value: 0 });
- });
- });
-});
diff --git a/src/renderer/containers/__tests__/AppRoutes-test.js b/src/renderer/containers/__tests__/AppRoutes-test.js
index cea9226c4..0a4aa1ef2 100644
--- a/src/renderer/containers/__tests__/AppRoutes-test.js
+++ b/src/renderer/containers/__tests__/AppRoutes-test.js
@@ -1,10 +1,17 @@
/* eslint-env jest */
import React from 'react';
-import { shallow } from 'enzyme';
-import { Redirect } from 'react-router';
+import { mount, shallow } from 'enzyme';
+import { MemoryRouter, Redirect } from 'react-router';
import AppRoutes from '../AppRoutes';
+jest.mock('../../components/TabBar', () => () => null);
+jest.mock('../PairDevice');
+jest.mock('../OverviewScreen');
+jest.mock('../SettingsScreen');
+jest.mock('../ExportScreen');
+jest.mock('../workspace/WorkspaceScreen', () => () =>
Workspace
);
+
describe('
', () => {
let wrapper;
const routesMatching = (routes, pattern) => (
@@ -23,14 +30,30 @@ describe('
', () => {
expect(routesMatching(wrapper.find('Route'), /settings/)).toHaveLength(1);
});
- // Not for Alpha.2
- // it('should contain data export', () => {
- // expect(routesMatching(wrapper.find('Route'), /export/)).toHaveLength(1);
- // });
+ it('should contain data export', () => {
+ expect(routesMatching(wrapper.find('Route'), /export/)).toHaveLength(1);
+ });
it('should redirect to overview by default', () => {
const defaultRedirect = wrapper.find(Redirect);
expect(defaultRedirect).toHaveLength(1);
expect(defaultRedirect.prop('to')).toMatch(/overview/);
});
+
+ describe('workspaces route', () => {
+ let screen;
+ beforeEach(() => {
+ const router =
;
+ const subject = mount(router);
+ screen = subject.find('main').find('Route').childAt(0);
+ });
+
+ it('mounts a WorkspaceScreen at the workspaces route', () => {
+ expect(screen.text()).toMatch('Workspace');
+ });
+
+ it('provides WorkspaceScreen with a ref to the app’s scroll container', () => {
+ expect(screen.prop('scrollContainerRef')).toHaveProperty('current');
+ });
+ });
});
diff --git a/src/renderer/containers/__tests__/WorkspaceScreen-test.js b/src/renderer/containers/__tests__/WorkspaceScreen-test.js
deleted file mode 100644
index 9e6d9a48b..000000000
--- a/src/renderer/containers/__tests__/WorkspaceScreen-test.js
+++ /dev/null
@@ -1,169 +0,0 @@
-/* eslint-env jest */
-import React from 'react';
-import { shallow } from 'enzyme';
-import { createStore } from 'redux';
-
-import ConnectedWorkspaceScreen, { UnconnectedWorkspaceScreen as WorkspaceScreen } from '../WorkspaceScreen';
-import AdminApiClient from '../../utils/adminApiClient';
-import { mockProtocol } from '../../../../config/jest/setupTestEnv';
-
-jest.mock('electron-log');
-jest.mock('../AnswerDistributionPanels', () => 'AnswerDistributionPanels');
-jest.mock('../../utils/adminApiClient');
-jest.mock('../../components/withApiClient', () => component => component);
-
-describe('
', () => {
- let wrapper;
- let mockApiClient;
-
- beforeEach(() => {
- mockApiClient = new AdminApiClient();
- mockApiClient.get.mockResolvedValue({ sessions: [] });
- wrapper = shallow((
-
- ));
- });
-
- it('renders a loading state', () => {
- expect(wrapper.find('Spinner')).toHaveLength(1);
- });
-
- it('renders a loading state until sessions load', () => {
- wrapper.setProps({ protocol: mockProtocol });
- expect(wrapper.find('Spinner')).toHaveLength(1);
- });
-
- it('renders dashboard panels once loaded', () => {
- wrapper.setProps({ protocol: mockProtocol });
- wrapper.setState({ sessions: [] });
- expect(wrapper.find('Spinner')).toHaveLength(0);
- expect(wrapper.find('.dashboard__panel').length).toBeGreaterThan(0);
- });
-
- it('loads sessions when new set imported', () => {
- wrapper.instance().loadSessions = jest.fn();
- wrapper.instance().onSessionsImported();
- expect(wrapper.instance().loadSessions).toHaveBeenCalled();
- });
-
- it('reloads sessions when protocol changes', () => {
- wrapper.instance().loadSessions = jest.fn();
- wrapper.setProps({ protocol: mockProtocol });
- expect(wrapper.instance().loadSessions).toHaveBeenCalled();
- });
-
- it('does not reload sessions when already set', () => {
- wrapper.instance().loadSessions = jest.fn();
- wrapper.setState({ sessions: [{}] });
- wrapper.setProps({ protocol: mockProtocol });
- expect(wrapper.instance().loadSessions).not.toHaveBeenCalled();
- });
-
- it('clears sessions if load errors', (done) => {
- mockApiClient.get.mockRejectedValue('err');
- wrapper.setProps({ protocol: mockProtocol, sessions: [{ id: 1 }] });
- // setImmediate: allow the load promise from setting protocol to flush
- setImmediate(() => {
- expect(wrapper.state()).toMatchObject({ sessions: [] });
- done();
- });
- });
-
- it('unsets sessions when protocol changes', () => {
- const state = { prevProtocolId: 1 };
- const props = { protocol: { id: 2 } };
- expect(WorkspaceScreen.getDerivedStateFromProps(props, state)).toMatchObject({
- sessions: null,
- });
- });
-
- it('retains state when protocol is same', () => {
- const state = { prevProtocolId: 1 };
- const props = { protocol: { id: 2 } };
- expect(WorkspaceScreen.getDerivedStateFromProps(state, props)).toBe(null);
- });
-
- it('deletes one session', () => {
- wrapper.setProps({ protocol: mockProtocol });
- wrapper.instance().deleteSession(4);
- expect(mockApiClient.delete).toHaveBeenCalledWith(wrapper.instance().sessionEndpoint(4));
- });
-
- it('deletes all sessions', () => {
- wrapper.setProps({ protocol: mockProtocol });
- wrapper.instance().deleteAllSessions();
- expect(mockApiClient.delete).toHaveBeenCalledWith(wrapper.instance().sessionsEndpoint);
- });
-
- it('cancels pending request when unmounted', () => {
- const instance = wrapper.instance();
- instance.loadPromise = {};
- wrapper.unmount();
- expect(instance.loadPromise.cancelled).toBe(true);
- });
-
- it('ignores cancellation when nothing outstanding', () => {
- const instance = wrapper.instance();
- instance.loadPromise = null;
- expect(() => wrapper.unmount()).not.toThrow();
- });
-
- describe('with a distribution variable', () => {
- const panelSelector = 'AnswerDistributionPanels';
- const protocol = {
- ...mockProtocol,
- variableRegistry: {
- node: {
- person: {
- name: 'person',
- variables: {},
- },
- },
- },
- };
-
- it('renders a distribution panel if an ordinal is available', () => {
- protocol.variableRegistry.node.person.variables = {
- ord: { label: 'ord', name: 'ord', type: 'ordinal' },
- };
- wrapper.setState({ sessions: [{}] });
- wrapper.setProps({ protocol });
- expect(wrapper.find(panelSelector)).toHaveLength(1);
- });
-
- it('renders a distribution panel if a categorical is available', () => {
- protocol.variableRegistry.node.person.variables = {
- cat: { label: 'cat', name: 'cat', type: 'categorical' },
- };
- wrapper.setState({ sessions: [{}] });
- wrapper.setProps({ protocol });
- expect(wrapper.find(panelSelector)).toHaveLength(1);
- });
-
- it('sets sessionCount to drive updates', () => {
- wrapper.setState({ sessions: [{}, {}], totalSessionsCount: 2 });
- wrapper.setProps({ protocol });
- expect(wrapper.find(panelSelector).prop('sessionCount')).toEqual(2);
- });
- });
-
- describe('when connected', () => {
- it('sets protocol based on store state & URL match', () => {
- const mockStore = createStore(() => (
- { protocols: [mockProtocol] }
- ));
- const subj = shallow((
-
- ));
- expect(subj.prop('protocol')).toEqual(mockProtocol);
- });
- });
-});
diff --git a/src/renderer/containers/index.js b/src/renderer/containers/index.js
index c8fb0dc99..fa5e97729 100644
--- a/src/renderer/containers/index.js
+++ b/src/renderer/containers/index.js
@@ -1,8 +1,6 @@
export { default as App } from './App';
export { default as ExportScreen } from './ExportScreen';
-export { default as InterviewStatsPanel } from './InterviewStatsPanel';
export { default as OverviewScreen } from './OverviewScreen';
export { default as PairDevice } from './PairDevice';
-export { default as ProtocolCountsPanel } from './ProtocolCountsPanel';
export { default as SettingsScreen } from './SettingsScreen';
-export { default as WorkspaceScreen } from './WorkspaceScreen';
+export { default as WorkspaceScreen } from './workspace/WorkspaceScreen';
diff --git a/src/renderer/containers/EntityTimeSeriesPanel.js b/src/renderer/containers/workspace/EntityTimeSeriesPanel.js
similarity index 92%
rename from src/renderer/containers/EntityTimeSeriesPanel.js
rename to src/renderer/containers/workspace/EntityTimeSeriesPanel.js
index 6abcb6280..b74b44b61 100644
--- a/src/renderer/containers/EntityTimeSeriesPanel.js
+++ b/src/renderer/containers/workspace/EntityTimeSeriesPanel.js
@@ -1,8 +1,8 @@
-import React, { Component } from 'react';
+import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
-import { EmptyData, TimeSeriesChart } from '../components';
-import withApiClient from '../components/withApiClient';
+import { EmptyData, TimeSeriesChart } from '../../components';
+import withApiClient from '../../components/withApiClient';
// Data series are keyed with node_[subtype] and edge_[subtype]; we can assume subtypes are
// meaningfully unique and label with just the subtype
@@ -32,7 +32,7 @@ const dataSeries = (timeSeriesKeys = []) => {
/**
* Render a line chart with each entity type as a series
*/
-class EntityTimeSeriesPanel extends Component {
+class EntityTimeSeriesPanel extends PureComponent {
constructor(props) {
super(props);
this.state = {
diff --git a/src/renderer/containers/InterviewStatsPanel.js b/src/renderer/containers/workspace/InterviewStatsPanel.js
similarity index 86%
rename from src/renderer/containers/InterviewStatsPanel.js
rename to src/renderer/containers/workspace/InterviewStatsPanel.js
index e3cb093c0..104098b96 100644
--- a/src/renderer/containers/InterviewStatsPanel.js
+++ b/src/renderer/containers/workspace/InterviewStatsPanel.js
@@ -1,9 +1,9 @@
-import React, { Component } from 'react';
+import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
-import InterviewWidget from '../components/charts/InterviewWidget';
-import withApiClient from '../components/withApiClient';
-import { formatDecimal } from '../utils/formatters';
+import InterviewWidget from '../../components/charts/InterviewWidget';
+import withApiClient from '../../components/withApiClient';
+import { formatDecimal } from '../../utils/formatters';
const shapeStatsData = ({ nodes = {}, edges = {} }) => ([
{ name: 'Node count',
@@ -22,7 +22,7 @@ const shapeStatsData = ({ nodes = {}, edges = {} }) => ([
},
]);
-class InterviewStatsPanel extends Component {
+class InterviewStatsPanel extends PureComponent {
constructor(props) {
super(props);
this.state = {
diff --git a/src/renderer/containers/ProtocolCountsPanel.js b/src/renderer/containers/workspace/ProtocolCountsPanel.js
similarity index 88%
rename from src/renderer/containers/ProtocolCountsPanel.js
rename to src/renderer/containers/workspace/ProtocolCountsPanel.js
index e2f99915f..b67b936cb 100644
--- a/src/renderer/containers/ProtocolCountsPanel.js
+++ b/src/renderer/containers/workspace/ProtocolCountsPanel.js
@@ -1,8 +1,8 @@
-import React, { Component } from 'react';
+import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
-import CountsWidget from '../components/charts/CountsWidget';
-import withApiClient from '../components/withApiClient';
+import CountsWidget from '../../components/charts/CountsWidget';
+import withApiClient from '../../components/withApiClient';
const shapeCountData = (nodeCount, edgeCount, sessionCount) => ([
{ name: 'Total Interviews', count: sessionCount },
@@ -10,7 +10,7 @@ const shapeCountData = (nodeCount, edgeCount, sessionCount) => ([
{ name: 'Total Edges', count: edgeCount },
]);
-class ProtocolCountsPanel extends Component {
+class ProtocolCountsPanel extends PureComponent {
constructor(props) {
super(props);
this.state = {
diff --git a/src/renderer/containers/workspace/WorkspaceScreen.js b/src/renderer/containers/workspace/WorkspaceScreen.js
new file mode 100644
index 000000000..d7aba4e13
--- /dev/null
+++ b/src/renderer/containers/workspace/WorkspaceScreen.js
@@ -0,0 +1,183 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import { arrayMove } from 'react-sortable-hoc';
+
+import Types from '../../types';
+import InterviewStatsPanel from './InterviewStatsPanel';
+import ProtocolCountsPanel from './ProtocolCountsPanel';
+import EntityTimeSeriesPanel from './EntityTimeSeriesPanel';
+import withAnswerDistributionCharts from './withAnswerDistributionCharts';
+import withSessions from './withSessions';
+import { Spinner } from '../../ui';
+import { selectors as protocolSelectors } from '../../ducks/modules/protocols';
+import {
+ actionCreators as layoutActionCreators,
+ selectors as layoutSelectors,
+} from '../../ducks/modules/panelLayoutOrders';
+import {
+ AnswerDistributionPanel,
+ ProtocolPanel,
+ ServerPanel,
+ SessionHistoryPanel,
+ SessionPanel,
+ SortablePanels,
+} from '../../components';
+
+class WorkspaceScreen extends Component {
+ /**
+ * Child components are defined here so that we can manage the sort order dynamically.
+ * @return {Array} panel components to be rendered. Not sorted.
+ */
+ get panels() {
+ const { answerDistributionCharts, protocol } = this.props;
+
+ if (!protocol) {
+ return [];
+ }
+
+ // session-related props are provided by `withSessions`
+ const { deleteAllSessions, deleteSession, sessions, totalSessionsCount } = this.props;
+ return [
+
,
+
,
+
,
+
deleteAllSessions()}
+ deleteSession={sessionId => deleteSession(sessionId)}
+ />,
+ ,
+ ,
+ ...answerDistributionCharts.map(chart => (
+
+ )),
+ ];
+ }
+
+ /**
+ * If a user sorts panels by dragging, the order of component keys is persisted in redux state
+ * under `panelLayoutOrder`. Because included charts may change, the defined order may not
+ * include all component keys, and may include keys that no longer correspond to rendered panels.
+ *
+ * @return {Array} panels sorted by key as specified by the user. Any additional panels
+ * (e.g., ones added after sort order was defined) are appended.
+ */
+ get sortedPanels() {
+ const { panelLayoutOrder } = this.props;
+ if (!panelLayoutOrder.length) {
+ return this.panels;
+ }
+
+ const unsorted = [];
+ const sorted = this.panels.reduce((acc, panel) => {
+ const index = panelLayoutOrder.indexOf(panel.key);
+ if (index >= 0) {
+ acc[index] = panel;
+ } else {
+ unsorted.push(panel);
+ }
+ return acc;
+ }, []);
+ return sorted.filter(Boolean).concat(unsorted);
+ }
+
+ render() {
+ const { protocol, sessions, setPanelLayoutOrder } = this.props;
+
+ if (!protocol || !sessions) {
+ return
;
+ }
+
+ const sortedPanels = this.sortedPanels;
+ const sortedPanelKeys = sortedPanels.map(panel => panel.key);
+ const onSortEnd = ({ oldIndex, newIndex }) => {
+ if (oldIndex !== newIndex) {
+ setPanelLayoutOrder(protocol.id, arrayMove(sortedPanelKeys, oldIndex, newIndex));
+ }
+ };
+
+ return (
+
+
+ this.props.scrollContainerRef.current}
+ className="dashboard"
+ helperClass="sortable--dragging"
+ panels={sortedPanels}
+ axis="xy"
+ onSortEnd={onSortEnd}
+ useDragHandle
+ />
+
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps) => ({
+ protocol: protocolSelectors.currentProtocol(state, ownProps),
+ panelLayoutOrder: layoutSelectors.panelLayoutOrderForCurrentProtocol(state, ownProps),
+});
+
+const mapDispatchToProps = dispatch => ({
+ setPanelLayoutOrder: bindActionCreators(layoutActionCreators.setPanelLayoutOrder, dispatch),
+});
+
+WorkspaceScreen.defaultProps = {
+ answerDistributionCharts: [],
+ protocol: null,
+ panelLayoutOrder: [],
+ sessions: null,
+ totalSessionsCount: null,
+ deleteSession: null,
+ deleteAllSessions: null,
+ scrollContainerRef: {},
+};
+
+WorkspaceScreen.propTypes = {
+ answerDistributionCharts: PropTypes.array,
+ protocol: Types.protocol,
+ panelLayoutOrder: PropTypes.array,
+ setPanelLayoutOrder: PropTypes.func.isRequired,
+ deleteAllSessions: PropTypes.func,
+ deleteSession: PropTypes.func,
+ sessions: PropTypes.array,
+ totalSessionsCount: PropTypes.number,
+ scrollContainerRef: PropTypes.object,
+};
+
+// withSessions & withAnswerDistributionCharts provide shared data for child components.
+// withSessions is at the top level, as it provides the totalSessionsCount which other containers
+// (including withAnswerDistributionCharts) use to drive updates. i.e., if a session is created or
+// deleted, all charts should re-render.
+const DataReadyWorkspaceScreen = withSessions(withAnswerDistributionCharts(WorkspaceScreen));
+
+export default connect(mapStateToProps, mapDispatchToProps)(DataReadyWorkspaceScreen);
+
+export { WorkspaceScreen as UnconnectedWorkspaceScreen };
diff --git a/src/renderer/containers/__tests__/EntityTimeSeriesPanel-test.js b/src/renderer/containers/workspace/__tests__/EntityTimeSeriesPanel-test.js
similarity index 90%
rename from src/renderer/containers/__tests__/EntityTimeSeriesPanel-test.js
rename to src/renderer/containers/workspace/__tests__/EntityTimeSeriesPanel-test.js
index 234065c78..da346ae79 100644
--- a/src/renderer/containers/__tests__/EntityTimeSeriesPanel-test.js
+++ b/src/renderer/containers/workspace/__tests__/EntityTimeSeriesPanel-test.js
@@ -3,10 +3,10 @@ import React from 'react';
import { mount } from 'enzyme';
import EntityTimeSeriesPanel from '../EntityTimeSeriesPanel';
-import AdminApiClient from '../../utils/adminApiClient';
+import AdminApiClient from '../../../utils/adminApiClient';
jest.mock('recharts');
-jest.mock('../../utils/adminApiClient', () => {
+jest.mock('../../../utils/adminApiClient', () => {
function MockApiClient() {}
MockApiClient.prototype.get = jest.fn().mockResolvedValue({
entities: [{ time: 1546455484765, node: 20, edge: 0 }],
diff --git a/src/renderer/containers/__tests__/InterviewStatsPanel-test.js b/src/renderer/containers/workspace/__tests__/InterviewStatsPanel-test.js
similarity index 91%
rename from src/renderer/containers/__tests__/InterviewStatsPanel-test.js
rename to src/renderer/containers/workspace/__tests__/InterviewStatsPanel-test.js
index e88b5e970..b3461bfcb 100644
--- a/src/renderer/containers/__tests__/InterviewStatsPanel-test.js
+++ b/src/renderer/containers/workspace/__tests__/InterviewStatsPanel-test.js
@@ -3,9 +3,9 @@ import React from 'react';
import { mount, shallow } from 'enzyme';
import InterviewStatsPanel from '../InterviewStatsPanel';
-import AdminApiClient from '../../utils/adminApiClient';
+import AdminApiClient from '../../../utils/adminApiClient';
-jest.mock('../../utils/adminApiClient');
+jest.mock('../../../utils/adminApiClient');
describe('InterviewStatsPanel', () => {
let mockApiClient;
diff --git a/src/renderer/containers/__tests__/ProtocolCountsPanel-test.js b/src/renderer/containers/workspace/__tests__/ProtocolCountsPanel-test.js
similarity index 94%
rename from src/renderer/containers/__tests__/ProtocolCountsPanel-test.js
rename to src/renderer/containers/workspace/__tests__/ProtocolCountsPanel-test.js
index f1ec9f2ec..e73865162 100644
--- a/src/renderer/containers/__tests__/ProtocolCountsPanel-test.js
+++ b/src/renderer/containers/workspace/__tests__/ProtocolCountsPanel-test.js
@@ -3,9 +3,9 @@ import React from 'react';
import { mount, shallow } from 'enzyme';
import ConnectedProtocolCountsPanel, { UnconnectedProtocolCountsPanel } from '../ProtocolCountsPanel';
-import AdminApiClient from '../../utils/adminApiClient';
+import AdminApiClient from '../../../utils/adminApiClient';
-jest.mock('../../utils/adminApiClient');
+jest.mock('../../../utils/adminApiClient');
const props = {
protocolId: '1',
diff --git a/src/renderer/containers/workspace/__tests__/WorkspaceScreen-test.js b/src/renderer/containers/workspace/__tests__/WorkspaceScreen-test.js
new file mode 100644
index 000000000..66d028d9c
--- /dev/null
+++ b/src/renderer/containers/workspace/__tests__/WorkspaceScreen-test.js
@@ -0,0 +1,88 @@
+/* eslint-env jest */
+import React from 'react';
+import { shallow } from 'enzyme';
+import { createStore } from 'redux';
+
+import ConnectedWorkspaceScreen, { UnconnectedWorkspaceScreen as WorkspaceScreen } from '../WorkspaceScreen';
+import { mockProtocol } from '../../../../../config/jest/setupTestEnv';
+
+jest.mock('electron-log');
+jest.mock('../withAnswerDistributionCharts', () => c => c);
+jest.mock('../withSessions', () => c => c);
+jest.mock('../../../components/withApiClient', () => component => component);
+
+describe('', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallow((
+
+ ));
+ });
+
+ it('renders a loading state', () => {
+ expect(wrapper.find('Spinner')).toHaveLength(1);
+ });
+
+ it('renders a loading state until sessions load', () => {
+ wrapper.setProps({ protocol: mockProtocol });
+ expect(wrapper.find('Spinner')).toHaveLength(1);
+ });
+
+ describe('with loaded sessions', () => {
+ beforeEach(() => {
+ wrapper.setProps({ protocol: mockProtocol, sessions: [] });
+ });
+
+ it('renders dashboard panels once loaded', () => {
+ expect(wrapper.find('Spinner')).toHaveLength(0);
+ expect(wrapper.find('ServerPanel').length).toBeGreaterThan(0);
+ });
+
+ it('renders a sortable list of panels once loaded', () => {
+ expect(wrapper.find('sortableList(Panels)')).toHaveLength(1);
+ });
+
+ it('orders panels', () => {
+ const unsortedKeys = wrapper.instance().panels.map(panel => panel.key);
+ const panelLayoutOrder = unsortedKeys.reverse();
+ wrapper.setProps({ panelLayoutOrder });
+ const sortedKeys = wrapper.instance().sortedPanels.map(panel => panel.key);
+ expect(sortedKeys).toEqual(panelLayoutOrder);
+ });
+ });
+
+ describe('when connected', () => {
+ const panelLayoutOrders = { [mockProtocol.id]: ['a', 'b'] };
+ const defaultState = { protocols: [mockProtocol], sessions: [] };
+ const makeSubjectWithState = state => shallow((
+ state)}
+ match={{ params: { id: mockProtocol.id } }}
+ />
+ ));
+
+ it('sets protocol based on store state & URL match', () => {
+ const subj = makeSubjectWithState({ ...defaultState, panelLayoutOrders });
+ expect(subj.prop('protocol')).toEqual(mockProtocol);
+ });
+
+ it('sets panel layout order', () => {
+ const subj = makeSubjectWithState({ ...defaultState, panelLayoutOrders });
+ expect(subj.prop('panelLayoutOrder')).toEqual(['a', 'b']);
+ });
+
+ it('sets a default order if none given', () => {
+ wrapper = makeSubjectWithState({ ...defaultState, panelLayoutOrders: {} });
+ expect(wrapper.prop('panelLayoutOrder')).toEqual([]);
+ });
+
+ it('provides setPanelLayoutOrder', () => {
+ const subj = makeSubjectWithState({ ...defaultState, panelLayoutOrders });
+ expect(subj.prop('setPanelLayoutOrder')).toBeInstanceOf(Function);
+ });
+ });
+});
diff --git a/src/renderer/containers/workspace/__tests__/withAnswerDistributionCharts-test.js b/src/renderer/containers/workspace/__tests__/withAnswerDistributionCharts-test.js
new file mode 100644
index 000000000..1f8d4589d
--- /dev/null
+++ b/src/renderer/containers/workspace/__tests__/withAnswerDistributionCharts-test.js
@@ -0,0 +1,97 @@
+/* eslint-env jest */
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+import { createStore } from 'redux';
+
+import withAnswerDistributionCharts from '../withAnswerDistributionCharts';
+import AdminApiClient from '../../../utils/adminApiClient';
+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 } } },
+ });
+ return MockApiClient;
+});
+
+jest.mock('../../../ducks/modules/protocols', () => ({
+ selectors: {
+ currentProtocol: jest.fn(),
+ currentProtocolId: jest.fn().mockReturnValue('1'),
+ isDistributionVariable: jest.fn().mockReturnValue(true),
+ transposedRegistry: jest.fn().mockReturnValue({
+ node: {
+ person: {
+ variables: {
+ distributionVariable: {
+ label: '',
+ type: 'ordinal',
+ options: [
+ { label: 'a', value: 1 },
+ { label: 'b', value: 2 },
+ { label: 'c', value: 3 },
+ ],
+ },
+ },
+ },
+ },
+ }),
+ },
+}));
+
+describe('AnswerDistributionPanels', () => {
+ let state;
+ let Wrapper;
+ let wrapper;
+ let mockApiClient;
+
+ beforeEach(() => {
+ state = { protocol: mockProtocol };
+ mockApiClient = new AdminApiClient();
+ Wrapper = withAnswerDistributionCharts(() => null);
+ wrapper = mount( state)} />);
+ });
+
+ it('loads data', () => {
+ expect(mockApiClient.get).toHaveBeenCalled();
+ });
+
+ it('loads data from API when totalSessionsCount changes', () => {
+ wrapper.setProps({ totalSessionsCount: 1 });
+ mockApiClient.get.mockClear();
+ expect(mockApiClient.get).toHaveBeenCalledTimes(0);
+ wrapper.setProps({ totalSessionsCount: 2 });
+ expect(mockApiClient.get).toHaveBeenCalledTimes(1);
+ });
+
+ it('loads data when protocolId changes', () => {
+ // shallow to bypass mapStateToProps
+ const wrapped = shallow( state)} />).dive();
+ wrapped.setProps({ protocolId: null });
+ mockApiClient.get.mockClear();
+ wrapped.setProps({ protocolId: '2' });
+ expect(mockApiClient.get).toHaveBeenCalled();
+ });
+
+ describe('API handler', () => {
+ beforeEach(async () => {
+ wrapper = shallow( state)} />).dive();
+ await wrapper.instance().loadData();
+ });
+
+ it('renders one chart per variable', () => {
+ expect(wrapper.state('charts')).toHaveLength(1);
+ });
+
+ 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 });
+ });
+
+ it('sets zeros for missing values', async () => {
+ expect(wrapper.state('charts')[0].chartData).toContainEqual({ name: 'c', value: 0 });
+ });
+ });
+});
diff --git a/src/renderer/containers/workspace/__tests__/withSessions-test.js b/src/renderer/containers/workspace/__tests__/withSessions-test.js
new file mode 100644
index 000000000..6a7e84da1
--- /dev/null
+++ b/src/renderer/containers/workspace/__tests__/withSessions-test.js
@@ -0,0 +1,100 @@
+/* eslint-env jest */
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import withSessions from '../withSessions';
+import AdminApiClient from '../../../utils/adminApiClient';
+import { mockProtocol } from '../../../../../config/jest/setupTestEnv';
+
+jest.mock('electron-log');
+jest.mock('../../../utils/adminApiClient');
+
+describe('withSessions HOC', () => {
+ let Wrapper;
+ let wrapper;
+ let mockApiClient;
+
+ beforeEach(() => {
+ mockApiClient = new AdminApiClient();
+ mockApiClient.get.mockResolvedValue({ sessions: [] });
+ Wrapper = withSessions(() => null);
+ wrapper = shallow();
+ });
+
+ it('loads sessions when new set imported', () => {
+ wrapper.instance().loadSessions = jest.fn();
+ wrapper.instance().onSessionsImported();
+ expect(wrapper.instance().loadSessions).toHaveBeenCalled();
+ });
+
+ // This should pass if getDerivedStateFromProps is called
+ it.skip('reloads sessions when protocol changes', () => {
+ wrapper.setProps({ protocol: { id: '0', name: '0', createdAt: new Date(0) } });
+ wrapper.instance().loadSessions = jest.fn();
+ wrapper.setProps({ protocol: mockProtocol });
+ expect(wrapper.instance().loadSessions).toHaveBeenCalled();
+ });
+
+ it('loads sessions on update when not set', () => {
+ wrapper.setState({ sessions: null });
+ wrapper.instance().loadSessions = jest.fn();
+ wrapper.setState({ foo: 1 }); // trigger componentDidUpdate
+ expect(wrapper.instance().loadSessions).toHaveBeenCalled();
+ });
+
+ it('does not reload sessions when already set', () => {
+ wrapper.setState({ sessions: [{}] });
+ wrapper.instance().loadSessions = jest.fn();
+ wrapper.setState({ foo: 1 }); // trigger componentDidUpdate
+ expect(wrapper.instance().loadSessions).not.toHaveBeenCalled();
+ });
+
+ it('clears sessions if load errors', (done) => {
+ mockApiClient.get.mockRejectedValue('err');
+ wrapper.setProps({ protocol: mockProtocol, sessions: [{ id: 1 }] });
+ // setImmediate: allow the load promise from setting protocol to flush
+ setImmediate(() => {
+ expect(wrapper.state()).toMatchObject({ sessions: [] });
+ done();
+ });
+ });
+
+ it('unsets sessions when protocol changes', () => {
+ const state = { prevProtocolId: 1 };
+ const props = { protocol: { id: 2 } };
+ expect(Wrapper.getDerivedStateFromProps(props, state)).toMatchObject({
+ sessions: null,
+ });
+ });
+
+ it('retains state when protocol is same', () => {
+ const state = { prevProtocolId: 1 };
+ const props = { protocol: { id: 2 } };
+ expect(Wrapper.getDerivedStateFromProps(state, props)).toBe(null);
+ });
+
+ it('deletes one session', () => {
+ wrapper.setProps({ protocol: mockProtocol });
+ wrapper.instance().deleteSession(4);
+ expect(mockApiClient.delete).toHaveBeenCalledWith(wrapper.instance().sessionEndpoint(4));
+ });
+
+ it('deletes all sessions', () => {
+ wrapper.setProps({ protocol: mockProtocol });
+ wrapper.instance().deleteAllSessions();
+ expect(mockApiClient.delete).toHaveBeenCalledWith(wrapper.instance().sessionsEndpoint);
+ });
+
+ it('cancels pending request when unmounted', () => {
+ const instance = wrapper.instance();
+ instance.loadPromise = {};
+ wrapper.unmount();
+ expect(instance.loadPromise.cancelled).toBe(true);
+ });
+
+ it('ignores cancellation when nothing outstanding', () => {
+ const instance = wrapper.instance();
+ instance.loadPromise = null;
+ expect(() => wrapper.unmount()).not.toThrow();
+ });
+});
diff --git a/src/renderer/containers/workspace/withAnswerDistributionCharts.js b/src/renderer/containers/workspace/withAnswerDistributionCharts.js
new file mode 100644
index 000000000..1f1665676
--- /dev/null
+++ b/src/renderer/containers/workspace/withAnswerDistributionCharts.js
@@ -0,0 +1,149 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+
+import AdminApiClient from '../../utils/adminApiClient';
+import { selectors as protocolSelectors } from '../../ducks/modules/protocols';
+import { selectors as variableSelectors } from '../../ducks/modules/excludedChartVariables';
+import Types from '../../types';
+
+const { currentProtocolId, isDistributionVariable, transposedRegistry } = protocolSelectors;
+const { excludedVariablesForCurrentProtocol } = variableSelectors;
+
+const hasData = bucket => bucket && Object.keys(bucket).length > 0;
+
+/**
+ * Translates the node variables and the data available for ordinal & categorical variables
+ * into a series of chart definitions.
+ *
+ * One chart definition is produced for each ordinal & categorical variable. If session data
+ * contains any answers for that variable, then the entire range of answers is returned (including
+ * `0` values). If no data is available for a variable, then the chart contains an empty chartData
+ * value, so the child component can easily render an empty data view.
+ *
+ * @private
+ *
+ * @param {Object} transposedNodeRegistry `transposedRegistry.node`, with transposed names
+ * @param {Object} buckets The API response from `option_buckets`
+ * @return {Array} chartDefinitions
+ */
+const shapeBucketData = (transposedNodeRegistry, buckets, excludedChartVariables) =>
+ Object.entries(transposedNodeRegistry).reduce((acc, [entityType, { variables }]) => {
+ const excludedSectionVariables = excludedChartVariables[entityType] || [];
+ Object.entries(variables).forEach(([variableName, def]) => {
+ if (!isDistributionVariable(def) || excludedSectionVariables.includes(def.name)) {
+ return;
+ }
+ const data = buckets[entityType] && buckets[entityType][variableName];
+ 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
+ const isOptionObject = option && typeof option === 'object';
+ // - label is optional, in which case `value` is used as the label
+ const name = isOptionObject ? (option.label || option.value) : option;
+ const dataKey = (isOptionObject ? option.value : option).toString();
+ return {
+ name,
+ value: data[dataKey] || 0,
+ };
+ });
+ acc.push({
+ entityType,
+ variableType: def.type,
+ variableDefinition: def,
+ chartData: values || [],
+ });
+ });
+ return acc;
+ }, []);
+
+/**
+ * HOC that provides chart definitions for the 'answer distribution' panels.
+ * Charts are exposed as a prop so that panel components can be managed directly
+ * (as an array) for sorting on the workspace before rendering.
+ */
+const withAnswerDistributionCharts = (WrappedComponent) => {
+ /**
+ * Renders a collection of ordinal & categorical distribution panels
+ */
+ const AnswerDistributionPanels = class extends Component {
+ static defaultProps = {
+ excludedChartVariables: {},
+ protocolId: null,
+ totalSessionsCount: null,
+ }
+
+ static propTypes = {
+ excludedChartVariables: PropTypes.object,
+ protocolId: PropTypes.string,
+ totalSessionsCount: PropTypes.number,
+ transposedRegistry: Types.variableRegistry.isRequired,
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ charts: [],
+ };
+ this.apiClient = new AdminApiClient();
+ }
+
+ componentDidMount() {
+ this.loadData();
+ }
+
+ componentDidUpdate(prevProps) {
+ const prevCount = prevProps.totalSessionsCount;
+ const newCount = this.props.totalSessionsCount;
+ // When mounted (on each workspace load), totalSessionsCount is null.
+ // Only reload data when session count changes (i.e., a session was
+ // imported or deleted while on this workspace).
+ if (newCount !== null && prevCount !== null && newCount !== prevCount) {
+ this.loadData();
+ } else if (prevProps.protocolId !== this.props.protocolId) {
+ this.loadData();
+ }
+ }
+
+ loadData() {
+ const {
+ excludedChartVariables,
+ protocolId,
+ transposedRegistry: { node: nodeRegistry = {} },
+ } = this.props;
+
+ if (!protocolId) {
+ return;
+ }
+
+ const variableNames = Object.values(nodeRegistry).reduce((acc, nodeTypeDefinition) => {
+ acc.push(...Object.keys(nodeTypeDefinition.variables || {}));
+ return acc;
+ }, []);
+
+ const route = `/protocols/${this.props.protocolId}/reports/option_buckets`;
+ const query = { variableNames };
+
+ this.apiClient.get(route, query)
+ .then(({ buckets }) => {
+ this.setState({
+ charts: shapeBucketData(nodeRegistry, buckets, excludedChartVariables),
+ });
+ });
+ }
+
+ render() {
+ return ;
+ }
+ };
+
+ const mapStateToProps = (state, ownProps) => ({
+ excludedChartVariables: excludedVariablesForCurrentProtocol(state, ownProps),
+ protocolId: currentProtocolId(state, ownProps),
+ transposedRegistry: transposedRegistry(state, ownProps),
+ });
+
+ return connect(mapStateToProps)(AnswerDistributionPanels);
+};
+
+export default withAnswerDistributionCharts;
diff --git a/src/renderer/containers/workspace/withSessions.js b/src/renderer/containers/workspace/withSessions.js
new file mode 100644
index 000000000..b88615f34
--- /dev/null
+++ b/src/renderer/containers/workspace/withSessions.js
@@ -0,0 +1,134 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import logger from 'electron-log';
+import { ipcRenderer } from 'electron';
+
+import Types from '../../types';
+import AdminApiClient from '../../utils/adminApiClient';
+import viewModelMapper from '../../utils/baseViewModelMapper';
+
+/**
+ * HOC to provide session-related data and functionality to a workspace.
+ *
+ * Defines the following props on a wrapped component:
+ *
+ * - deleteAllSessions
+ * - deleteSession
+ * - sessions
+ * - totalSessionsCount
+ */
+const withSessions = WrappedComponent =>
+ class extends Component {
+ static defaultProps = {
+ protocol: null,
+ };
+
+ static propTypes = {
+ protocol: Types.protocol,
+ };
+
+ static getDerivedStateFromProps(props, state) {
+ if (props.protocol && props.protocol.id !== state.prevProtocolId) {
+ // Protocol has changed; reset data to trigger new load
+ return {
+ prevProtocolId: props.protocol.id,
+ sessions: null,
+ totalSessionsCount: null,
+ };
+ }
+ return null;
+ }
+
+ constructor(props) {
+ super(props);
+ this.apiClient = new AdminApiClient();
+ this.state = {
+ sessions: [],
+ totalSessionsCount: null,
+ };
+ }
+
+ componentDidMount() {
+ this.loadSessions();
+ }
+
+ componentDidUpdate() {
+ if (!this.state.sessions) {
+ this.loadSessions();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.loadPromise) { this.loadPromise.cancelled = true; }
+ ipcRenderer.removeListener('SESSIONS_IMPORTED', this.onSessionsImported);
+ }
+
+ onSessionsImported = () => this.loadSessions();
+
+ get sessionsEndpoint() {
+ const id = this.props.protocol.id;
+ return id && `/protocols/${id}/sessions`;
+ }
+
+ sessionEndpoint(sessionId) {
+ const base = this.sessionsEndpoint;
+ return base && `${base}/${sessionId}`;
+ }
+
+ loadSessions() {
+ const { protocol } = this.props;
+ if (!protocol || this.loadPromise) {
+ return;
+ }
+ this.loadPromise = this.apiClient.get(this.sessionsEndpoint)
+ .then((resp) => {
+ if (!this.loadPromise.cancelled) {
+ const sessions = resp.sessions.map(viewModelMapper);
+ this.setState({ sessions, totalSessionsCount: resp.totalSessions });
+ }
+ })
+ .catch((err) => {
+ if (!this.loadPromise.cancelled) {
+ logger.error(err);
+ this.setState({ sessions: [] });
+ }
+ })
+ .then(() => { this.loadPromise = null; });
+ }
+
+ deleteAllSessions = () => {
+ this.apiClient.delete(this.sessionsEndpoint)
+ .then(() => this.loadSessions());
+ }
+
+ deleteSession = (sessionId) => {
+ this.apiClient.delete(this.sessionEndpoint(sessionId))
+ .then(() => this.loadSessions());
+ }
+
+ render() {
+ const { sessions, totalSessionsCount } = this.state;
+ return (
+
+ );
+ }
+ };
+
+const providedPropTypes = {
+ deleteAllSessions: PropTypes.function,
+ deleteSession: PropTypes.function,
+ sessions: PropTypes.array,
+ totalSessionsCount: PropTypes.number,
+};
+
+export default withSessions;
+
+export {
+ providedPropTypes,
+};
diff --git a/src/renderer/ducks/modules/panelLayoutOrders.js b/src/renderer/ducks/modules/panelLayoutOrders.js
new file mode 100644
index 000000000..60d99d7fa
--- /dev/null
+++ b/src/renderer/ducks/modules/panelLayoutOrders.js
@@ -0,0 +1,53 @@
+import { selectors as protocolSelectors } from './protocols';
+
+const SET_PANEL_LAYOUT_ORDER = 'SET_PANEL_LAYOUT_ORDER';
+
+const initialState = {};
+
+const reducer = (state = initialState, action = {}) => {
+ switch (action.type) {
+ case SET_PANEL_LAYOUT_ORDER: {
+ const protocolId = action.protocolId;
+ if (!protocolId) {
+ return state;
+ }
+ return { ...state, [protocolId]: action.layoutOrder };
+ }
+ default:
+ return state;
+ }
+};
+
+/**
+ */
+const setPanelLayoutOrder = (protocolId, layoutOrder) => ({
+ type: SET_PANEL_LAYOUT_ORDER,
+ protocolId,
+ layoutOrder,
+});
+
+
+const panelLayoutOrderForCurrentProtocol = (state, props) => {
+ const protocol = protocolSelectors.currentProtocol(state, props);
+ return (protocol && state.panelLayoutOrders[protocol.id]) || [];
+};
+
+const actionCreators = {
+ setPanelLayoutOrder,
+};
+
+const actionTypes = {
+ SET_PANEL_LAYOUT_ORDER,
+};
+
+const selectors = {
+ panelLayoutOrderForCurrentProtocol,
+};
+
+export {
+ actionCreators,
+ actionTypes,
+ selectors,
+};
+
+export default reducer;
diff --git a/src/renderer/ducks/modules/protocols.js b/src/renderer/ducks/modules/protocols.js
index dc310afc7..5b579c7da 100644
--- a/src/renderer/ducks/modules/protocols.js
+++ b/src/renderer/ducks/modules/protocols.js
@@ -34,12 +34,19 @@ const reducer = (state = initialState, action = {}) => {
};
// Select the current protocol based either on a `protocolId` prop or 'id' in the routing params
+// May return undefined
const currentProtocol = (state, props) => {
const protocols = state.protocols;
const id = props.protocolId || (props.match && props.match.params.id);
return protocols && id && protocols.find(p => p.id === id);
};
+// Return the ID of the current protocol, only if it exists.
+const currentProtocolId = (state, props) => {
+ const protocol = currentProtocol(state, props);
+ return protocol && protocol.id;
+};
+
// Transpose all types & variable IDs to names
// Imported data is transposed; this allows utility components from Architect to work as-is.
const transposedRegistry = (state, props) => {
@@ -130,6 +137,7 @@ const actionTypes = {
const selectors = {
currentProtocol,
+ currentProtocolId,
isDistributionVariable,
protocolsHaveLoaded,
transposedRegistry,
diff --git a/src/renderer/ducks/modules/rootReducer.js b/src/renderer/ducks/modules/rootReducer.js
index c6e3c517d..f80b3687a 100644
--- a/src/renderer/ducks/modules/rootReducer.js
+++ b/src/renderer/ducks/modules/rootReducer.js
@@ -5,6 +5,7 @@ import connectionInfo from './connectionInfo';
import devices from './devices';
import excludedChartVariables from './excludedChartVariables';
import pairingRequest from './pairingRequest';
+import panelLayoutOrders from './panelLayoutOrders';
import protocols from './protocols';
const appReducer = combineReducers({
@@ -13,6 +14,7 @@ const appReducer = combineReducers({
devices,
excludedChartVariables,
pairingRequest,
+ panelLayoutOrders,
protocols,
});
diff --git a/src/renderer/ducks/store.js b/src/renderer/ducks/store.js
index 09a91adfb..df04d5095 100644
--- a/src/renderer/ducks/store.js
+++ b/src/renderer/ducks/store.js
@@ -18,5 +18,5 @@ export const store = createStore(
export const persistor = persistStore(
store,
- { whitelist: ['excludedChartVariables'] },
+ { whitelist: ['excludedChartVariables', 'panelLayoutOrders'] },
);
diff --git a/src/renderer/styles/_variables.scss b/src/renderer/styles/_variables.scss
index 3a1f6c8cf..a2a0f28ea 100644
--- a/src/renderer/styles/_variables.scss
+++ b/src/renderer/styles/_variables.scss
@@ -1,8 +1,11 @@
:root {
+ $box-shadow-color: rgba(0, 0, 0, 0.3);
+
// Colors
--base-background-color: var(--color-platinum);
--base-border-color: var(--color-platinum--dark);
- --dashboard-panel-accent-color: var(--color-sea-serpent);
+ --box-shadow-color: #{$box-shadow-color};
+ --draggable-color: var(--color-sea-serpent);
--inverse-background-color: var(--color-navy-taupe);
--inverse-text-color: var(--color-white);
--message-background-color: var(--color-sea-green--dark);
@@ -22,6 +25,7 @@
// Layout
--app-sidebar-width: 9rem;
--app-titlebar-height: 22px;
+ --draggable-height: 0.8rem;
+ --draggable-border-radius: 0 0 0.25rem 0.25rem;
--modal-window-padding: #{spacing(huge)};
- --dashboard-panel-accent-radius: 4px;
}
diff --git a/src/renderer/styles/components/_all.scss b/src/renderer/styles/components/_all.scss
index e997f9b84..909b15a4d 100644
--- a/src/renderer/styles/components/_all.scss
+++ b/src/renderer/styles/components/_all.scss
@@ -20,5 +20,6 @@
@import './scrollable';
@import './server-panel';
@import './session-panel';
+@import './sortable';
@import './tab-bar';
@import './workspace';
diff --git a/src/renderer/styles/components/_dashboard.scss b/src/renderer/styles/components/_dashboard.scss
index 2eb54c936..dd5206261 100644
--- a/src/renderer/styles/components/_dashboard.scss
+++ b/src/renderer/styles/components/_dashboard.scss
@@ -1,20 +1,21 @@
.dashboard {
$min-grid-row: 0;
$max-grid-row: 27rem;
- $top-grid-row: auto;
- $second-grid-row: auto;
$column-count: 3;
+ $grid-gap: spacing(medium);
display: grid;
grid-auto-rows: minmax($min-grid-row, $max-grid-row);
- grid-gap: spacing(medium);
+ // Drag handles are rendered as bars below each panel; add extra vertical gap to accommodate:
+ grid-gap: calc(#{$grid-gap} + var(--draggable-height)) $grid-gap;
grid-template-columns: repeat($column-count, 1fr);
- grid-template-rows: $top-grid-row $second-grid-row;
@include element(panel) {
background-color: var(--color-white);
+ height: 100%;
padding: spacing(large);
position: relative;
+ width: 100%;
@include modifier(chart) {
display: flex;
@@ -35,15 +36,6 @@
margin-bottom: var(--scrolling-panel-header-margin);
}
}
-
- @include modifier(server-stats) {
- grid-area: 1 / span $column-count;
- }
-
- &:not(:first-child) {
- border-bottom: var(--dashboard-panel-accent-radius) solid var(--dashboard-panel-accent-color);
- border-radius: 0 0 var(--dashboard-panel-accent-radius) var(--dashboard-panel-accent-radius);
- }
}
@include element(chartContainer) {
diff --git a/src/renderer/styles/components/_panel-item.scss b/src/renderer/styles/components/_panel-item.scss
index f41b13b6a..b920c9e34 100644
--- a/src/renderer/styles/components/_panel-item.scss
+++ b/src/renderer/styles/components/_panel-item.scss
@@ -1,4 +1,6 @@
.panel-item {
+ margin: spacing(medium);
+
@include element(placeholder) {
font-style: italic;
}
diff --git a/src/renderer/styles/components/_server-panel.scss b/src/renderer/styles/components/_server-panel.scss
index 1e3de33f5..0200190a0 100644
--- a/src/renderer/styles/components/_server-panel.scss
+++ b/src/renderer/styles/components/_server-panel.scss
@@ -1,4 +1,6 @@
.server-panel {
+ background-color: var(--color-white);
display: flex;
+ margin-bottom: spacing(medium);
justify-content: space-between;
}
diff --git a/src/renderer/styles/components/_sortable.scss b/src/renderer/styles/components/_sortable.scss
new file mode 100644
index 000000000..229436708
--- /dev/null
+++ b/src/renderer/styles/components/_sortable.scss
@@ -0,0 +1,20 @@
+.sortable {
+ @include modifier(dragging) {
+ box-shadow: 0 1rem 1.5rem var(--box-shadow-color);
+
+ // Override inline pointer-events style to allow 'grabbing' cursor to display
+ pointer-events: auto !important; // sass-lint:disable-line no-important
+
+ .sortable__handle {
+ cursor: grabbing;
+ }
+ }
+
+ @include element(handle) {
+ background-color: var(--draggable-color);
+ border-radius: var(--draggable-border-radius);
+ cursor: grab;
+ height: var(--draggable-height);
+ width: 100%;
+ }
+}
diff --git a/src/renderer/styles/components/_workspace.scss b/src/renderer/styles/components/_workspace.scss
index 02c381654..d38471eaa 100644
--- a/src/renderer/styles/components/_workspace.scss
+++ b/src/renderer/styles/components/_workspace.scss
@@ -8,8 +8,4 @@
display: flex;
justify-content: center;
}
-
- .panel-item {
- margin: spacing(medium);
- }
}