diff --git a/src/main/server/__tests__/AdminService-test.js b/src/main/server/__tests__/AdminService-test.js index 3aba71fa5..8264e5102 100644 --- a/src/main/server/__tests__/AdminService-test.js +++ b/src/main/server/__tests__/AdminService-test.js @@ -6,6 +6,7 @@ const { jsonClient, makeUrl } = require('../../../../config/jest/setupTestEnv'); const DeviceManager = require('../../data-managers/DeviceManager'); const ProtocolManager = require('../../data-managers/ProtocolManager'); +jest.mock('nedb'); jest.mock('electron-log'); jest.mock('../../data-managers/DeviceManager'); jest.mock('../../data-managers/ProtocolManager'); diff --git a/src/main/server/__tests__/apiRequestLogger-test.js b/src/main/server/__tests__/apiRequestLogger-test.js index 6aba49401..031587e8a 100644 --- a/src/main/server/__tests__/apiRequestLogger-test.js +++ b/src/main/server/__tests__/apiRequestLogger-test.js @@ -11,6 +11,6 @@ const mockNext = jest.fn(); describe('API request logger', () => { it('logs requests as info', () => { apiRequestLogger()(mockReq, mockRes, mockNext); - expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); }); }); diff --git a/src/main/server/apiRequestLogger.js b/src/main/server/apiRequestLogger.js index 92087a696..66854bde9 100644 --- a/src/main/server/apiRequestLogger.js +++ b/src/main/server/apiRequestLogger.js @@ -8,7 +8,7 @@ const format = (req, res, tag = 'API') => ( * Logging plugin to be used on restify's `after` event */ const apiRequestLogger = tag => (req, res/* , route, err */) => { - logger.info(format(req, res, tag)); + logger.debug(format(req, res, tag)); }; module.exports = apiRequestLogger; diff --git a/src/renderer/components/index.js b/src/renderer/components/index.js index 118c9b0cc..b0e24d051 100644 --- a/src/renderer/components/index.js +++ b/src/renderer/components/index.js @@ -1,3 +1,4 @@ +export { default as AnswerDistributionPanel } from './workspace/AnswerDistributionPanel'; export { default as AppMessage } from './AppMessage'; export { default as BarChart } from './charts/BarChart'; export { default as CountsWidget } from './charts/CountsWidget'; @@ -11,15 +12,17 @@ export { default as InterviewWidget } from './charts/InterviewWidget'; export { default as TimeSeriesChart } from './charts/TimeSeriesChart'; export { default as Modal } from './Modal'; export { default as Overflow } from './Overflow'; -export { default as PanelItem } from './PanelItem'; +export { default as PanelItem } from './workspace/PanelItem'; export { default as PairPrompt } from './pairing/PairPrompt'; export { default as PairPin } from './pairing/PairPin'; export { default as PairedDeviceModal } from './PairedDeviceModal'; export { default as PieChart } from './charts/PieChart'; -export { default as ProtocolPanel } from './ProtocolPanel'; +export { default as ProtocolPanel } from './workspace/ProtocolPanel'; export { default as Scrollable } from './Scrollable'; export { default as ScrollingPanelItem } from './ScrollingPanelItem'; -export { default as ServerPanel } from './ServerPanel'; -export { default as SessionHistoryPanel } from './SessionHistoryPanel'; -export { default as SessionPanel } from './SessionPanel'; +export { default as ServerPanel } from './workspace/ServerPanel'; +export { default as SessionHistoryPanel } from './workspace/SessionHistoryPanel'; +export { default as SessionPanel } from './workspace/SessionPanel'; +export { default as SortablePanel } from './workspace/SortablePanel'; +export { default as SortablePanels } from './workspace/SortablePanels'; export { default as TabBar } from './TabBar'; diff --git a/src/renderer/components/AnswerDistributionPanel.js b/src/renderer/components/workspace/AnswerDistributionPanel.js similarity index 50% rename from src/renderer/components/AnswerDistributionPanel.js rename to src/renderer/components/workspace/AnswerDistributionPanel.js index 219831de1..2da4549cc 100644 --- a/src/renderer/components/AnswerDistributionPanel.js +++ b/src/renderer/components/workspace/AnswerDistributionPanel.js @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import Types from '../types'; -import { BarChart, EmptyData, PieChart } from '../components'; +import Types from '../../types'; +import { BarChart, EmptyData, PieChart } from '../../components'; const sumValues = groups => groups.reduce((sum, group) => sum + group.value, 0); @@ -22,28 +22,31 @@ const content = (chartData, variableType) => { * Depending on variableType, renders either a pie chart with a distribution of categorical * node attributes, or a Bar chart with a distribution of ordinal attributes. */ -const AnswerDistributionPanel = ({ chartData, variableDefinition }) => { - const totalObservations = sumValues(chartData); - return ( -
-

- {variableDefinition.label} - - {headerLabel(variableDefinition.type)} distribution - -

-
- {content(chartData, variableDefinition.type)} +class AnswerDistributionPanel extends PureComponent { + render() { + const { chartData, variableDefinition } = this.props; + const totalObservations = sumValues(chartData); + return ( +
+

+ {variableDefinition.label} + + {headerLabel(variableDefinition.type)} distribution + +

+
+ {content(chartData, variableDefinition.type)} +
+
+ { + totalObservations > 0 && + `Total: ${totalObservations} observations` + } +
-
- { - totalObservations > 0 && - `Total: ${totalObservations} observations` - } -
-
- ); -}; + ); + } +} 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 ( +
+ { children } + +
+ ); + } +} + +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); - } }