diff --git a/lib/constants/visualise.js b/lib/constants/visualise.js index 3d8ca848c8..656595f610 100644 --- a/lib/constants/visualise.js +++ b/lib/constants/visualise.js @@ -1,5 +1,6 @@ export const VISUALISE_AXES_PREFIX = 'axes'; export const MAX_CUSTOM_COLORS = 7; +export const RESPONSE_ROWS_LIMIT = 10000; // Visualisation Types export const LEADERBOARD = 'LEADERBOARD'; diff --git a/lib/models/visualisation.js b/lib/models/visualisation.js index 7d620be20b..8c717877ec 100644 --- a/lib/models/visualisation.js +++ b/lib/models/visualisation.js @@ -105,6 +105,8 @@ const schema = new mongoose.Schema({ statementContents: [statementContents], hasBeenMigrated: { type: Boolean, default: false }, timezone: { type: String }, + showStats: { type: Boolean, default: true }, + statsAtBottom: { type: Boolean, default: true }, }); schema.readScopes = _.keys(scopes.USER_SCOPES); diff --git a/ui/src/components/ScrollableTable/index.js b/ui/src/components/ScrollableTable/index.js new file mode 100644 index 0000000000..e61cd9f30f --- /dev/null +++ b/ui/src/components/ScrollableTable/index.js @@ -0,0 +1,158 @@ +import React, { useEffect, useState, createRef } from 'react'; +import withStyles from 'isomorphic-style-loader/lib/withStyles'; +import _ from 'lodash'; +import styles from './styles.css'; + +/** + * @param {React.RefObject} ref + * @returns {number|null} + */ +const refToClientHeight = (ref) => { + if (ref && ref.current && ref.current.clientHeight) { + return ref.current.clientHeight; + } + return null; +}; + +/** + * @param {(number|null)[]} hs1 + * @param {(number|null)[]} hs2 + * @returns {boolean} + */ +const areSameHeights = (hs1, hs2) => + hs1.length === hs2.length && hs1.every((h, i) => h === hs2[i]); + +/** + * @param {boolean} fixTHead - default is false + * @param {boolean} fixTFoot - default is false + * @param {array} children + * 1 or 0 thead + * 1 or 0 tbody + * 1 or 0 tfoot + * + * Children of , , and should be or an array of + * Children of should be , , or an array of or + */ +const ScrollableTable = ({ + fixTHead = false, + fixTFoot = false, + children = [], +}) => { + const tHead = children.find(c => c.type === 'thead'); + const tBody = children.find(c => c.type === 'tbody'); + const tFoot = children.find(c => c.type === 'tfoot'); + + const trsInTHead = tHead ? (tHead.props.children || []).flat().filter(c => c.type === 'tr') : []; + const trsInTBody = tBody ? (tBody.props.children || []).flat().filter(c => c.type === 'tr') : []; + const trsInTFoot = tFoot ? (tFoot.props.children || []).flat().filter(c => c.type === 'tr') : []; + + const tHeadTrsRefs = trsInTHead.map(() => createRef()); + const tFootTrsRefs = trsInTFoot.map(() => createRef()); + + const [tHeadTrsHeights, updateTHeadTrsHeights] = useState([]); + const [tFootTrsHeights, updateTFootTrsHeights] = useState([]); + + useEffect(() => { + const tHeadTrsHeightsFromRefs = tHeadTrsRefs.map(refToClientHeight); + if (!areSameHeights(tHeadTrsHeights, tHeadTrsHeightsFromRefs)) { + updateTHeadTrsHeights(tHeadTrsHeightsFromRefs); + } + + const tFootTrsHeightsFromRefs = tFootTrsRefs.map(refToClientHeight); + if (!areSameHeights(tFootTrsHeights, tFootTrsHeightsFromRefs)) { + updateTFootTrsHeights(tFootTrsHeightsFromRefs); + } + }); + + const tHeadTrsTops = tHeadTrsHeights.reduce((acc, height) => { + const previousTop = acc[acc.length - 1]; + if (previousTop === null || height === null) { + return [...acc, null]; + } + return [...acc, previousTop + height]; + }, [0]); + + const tFootTrsBottoms = [...tFootTrsHeights].reverse().reduce((acc, height) => { + const previousBottom = acc[0]; + if (previousBottom === null || height === null) { + return [null, ...acc]; + } + return [previousBottom + height, ...acc]; + }, [0]); + + const tHeadClass = styles.greyStartingStripe; + const tBodyClass = (trsInTHead.length % 2 === 0) ? styles.greyStartingStripe : styles.whiteStartingStripe; + const tFootClass = ((trsInTHead.length + trsInTBody.length) % 2 === 0) ? styles.greyStartingStripe : styles.whiteStartingStripe; + + return ( +
+ + {tHead && ( + + { + trsInTHead.map((tr, i) => { + const childStyle = (fixTHead && _.isNumber(tHeadTrsTops[i])) ? { position: 'sticky', top: tHeadTrsTops[i] } : {}; + return ( + + { + tr.props.children + .flat() + .filter(cell => ['td', 'th'].some(t => t === cell.type)) + .map((cell, j) => React.createElement( + cell.type, + { + ...cell.props, + key: cell.key || `thead-tr-c-${i}-${j}`, + style: childStyle + }, + )) + } + + ); + }) + } + + )} + + {tBody && ( + + {trsInTBody} + + )} + + {tFoot && ( + + { + trsInTFoot.flat().map((tr, i) => { + const childStyle = (fixTFoot && _.isNumber(tFootTrsBottoms[i + 1])) ? { position: 'sticky', bottom: tFootTrsBottoms[i + 1] } : {}; + return ( + + { + tr.props.children + .flat() + .filter(cell => ['td', 'th'].some(t => t === cell.type)) + .map((cell, j) => React.createElement( + cell.type, + { + ...cell.props, + key: cell.key || `tfoot-tr-c-${i}-${j}`, + style: childStyle + }, + )) + } + + ); + }) + } + + )} +
+
+ ); +}; + +export default withStyles(styles)(ScrollableTable); diff --git a/ui/src/components/ScrollableTable/styles.css b/ui/src/components/ScrollableTable/styles.css new file mode 100644 index 0000000000..6b09658d4f --- /dev/null +++ b/ui/src/components/ScrollableTable/styles.css @@ -0,0 +1,79 @@ +.tableContainer { + height: 100%; + overflow-x: scroll; + overflow-y: scroll; +} + +.table { + border-collapse: separate; + margin: 0; + border: 0; +} + +/** + * core.css defines the color of odd-number-th lines + * + * .table-striped > tbody > tr:nth-of-type(odd) { + * background-color: #f9f9f9; + * } + */ +.greyStartingStripe > tr:nth-child(even) > th, +.greyStartingStripe > tr:nth-child(even) > td, +.whiteStartingStripe > tr:nth-child(odd) > th, +.whiteStartingStripe > tr:nth-child(odd) > td { + background-color: #ffffff; +} + +.greyStartingStripe > tr:nth-child(odd) > th, +.greyStartingStripe > tr:nth-child(odd) > td, +.whiteStartingStripe > tr:nth-child(even) > th, +.whiteStartingStripe > tr:nth-child(even) > td { + background-color: #f9f9f9; +} + +.table > thead > tr > th, +.table > thead > tr > td, +.table > tbody > tr > th, +.table > tbody > tr > td, +.table > tfoot > tr > th, +.table > tfoot > tr > td { + border: 0; +} + +.table > thead > tr > th, +.table > thead > tr > td, +.table > tfoot > tr > th, +.table > tfoot > tr > td { + border-top: 1px solid #ddd !important; + border-left: 1px solid #ddd !important; +} + +.table > thead > tr:last-child > th, +.table > thead > tr:last-child > td, +.table > tfoot > tr:last-child > th, +.table > tfoot > tr:last-child > td { + border-bottom: 1px solid #ddd !important; +} + +.table > thead > tr > th:last-child, +.table > thead > tr > td:last-child, +.table > tfoot > tr > th:last-child, +.table > tfoot > tr > td:last-child { + border-right: 1px solid #ddd !important; +} + +.table > tbody > tr > th, +.table > tbody > tr > td { + border-left: 1px solid #ddd !important; + border-bottom: 1px solid #ddd !important; +} + +.table > tbody > tr:last-child > th, +.table > tbody > tr:last-child > td { + border-left: 1px solid #ddd !important; +} + +.table > tbody > tr > th:last-child, +.table > tbody > tr > td:last-child { + border-right: 1px solid #ddd !important; +} diff --git a/ui/src/components/Table/index.js b/ui/src/components/Table/index.js deleted file mode 100644 index eba5eae83c..0000000000 --- a/ui/src/components/Table/index.js +++ /dev/null @@ -1,154 +0,0 @@ -import React from 'react'; -import withStyles from 'isomorphic-style-loader/lib/withStyles'; -import { Grid, AutoSizer, ScrollSync } from 'react-virtualized'; -import { range, sum } from 'lodash'; -import styles from './styles.css'; - -const defaultFrozenCellsRenderer = ({ key, style, ...props }) => ( -
- -
-); - -const defaultUnfrozenCellsRenderer = ({ key, style, ...props }) => ( -
- -
-); - -const getCellsSize = (size, numberOfCells) => - sum(range(numberOfCells).map(index => size({ index }))); - -const getCellSize = (frozenSize, frozenCells, size) => index => ( - index === 0 ? - getCellsSize(frozenSize, frozenCells) : - size - (getCellsSize(frozenSize, frozenCells)) -); - -const defaultTableRenderer = ({ - renderFrozenCells = defaultFrozenCellsRenderer, - renderUnfrozenCells = defaultUnfrozenCellsRenderer, - renderFrozenCornerCell, - renderFrozenRowCell, - renderFrozenColumnCell, - renderUnfrozenCell, - unfrozenColumns = 0, - unfrozenRows = 0, - columnWidth, - rowHeight, - frozenColumnWidth = columnWidth, - frozenRowHeight = rowHeight, - frozenRows = 0, - frozenColumns = 0, - overscanRowCount = 40, - width, - height, - onSectionRendered = () => null, - scrollLeft, - scrollTop, - onScroll, -}) => ( -
- getCellSize(frozenColumnWidth, frozenColumns, width)(index)} - rowHeight={({ index }) => getCellSize(frozenRowHeight, frozenRows, height)(index)} - rowCount={2} - columnCount={2} - cellRenderer={({ rowIndex, columnIndex, style }) => { - if (rowIndex === 0 && columnIndex === 0) { - return renderFrozenCells({ - key: 'frozenCorner', - overscanColumnCount: 0, - overscanRowCount: 0, - rowCount: frozenRows, - columnCount: frozenColumns, - cellRenderer: renderFrozenCornerCell, - columnWidth: frozenColumnWidth, - rowHeight: frozenRowHeight, - width: getCellSize(frozenColumnWidth, frozenColumns, width)(0), - height: getCellSize(frozenRowHeight, frozenRows, height)(0), - style, - }); - } - if (rowIndex === 0 && columnIndex === 1) { - return renderFrozenCells({ - key: 'frozenRow', - overscanColumnCount: 0, - overscanRowCount: 0, - rowCount: frozenRows, - columnCount: unfrozenColumns, - cellRenderer: renderFrozenRowCell, - columnWidth, - rowHeight: frozenRowHeight, - width: getCellSize(frozenColumnWidth, frozenColumns, width)(1), - height: getCellSize(frozenRowHeight, frozenRows, height)(0), - style, - scrollLeft, - }); - } - if (rowIndex === 1 && columnIndex === 0) { - return renderFrozenCells({ - key: 'frozenColumns', - overscanColumnCount: 0, - overscanRowCount: 0, - rowCount: unfrozenRows, - columnCount: frozenColumns, - cellRenderer: renderFrozenColumnCell, - columnWidth: frozenColumnWidth, - rowHeight, - width: getCellSize(frozenColumnWidth, frozenColumns, width)(0), - height: getCellSize(frozenRowHeight, frozenRows, height)(1), - style, - styles, - scrollTop, - }); - } - if (rowIndex === 1 && columnIndex === 1) { - return renderUnfrozenCells({ - key: 'unfrozen', - overscanColumnCount: 0, - overscanRowCount, - rowCount: unfrozenRows, - columnCount: unfrozenColumns, - cellRenderer: renderUnfrozenCell, - columnWidth, - rowHeight, - width: getCellSize(frozenColumnWidth, frozenColumns, width)(1), - height: getCellSize(frozenRowHeight, frozenRows, height)(1), - style, - onSectionRendered, - scrollLeft, - scrollTop, - onScroll, - }); - } - throw new Error(`Unexpected row (${rowIndex}) or column (${columnIndex}) index`); - }} /> -
-); - -const renderAutoSizeTable = ({ - renderTable = defaultTableRenderer, - ...props, -}) => ( - - {({ width, height }) => ( - - {({ onScroll, scrollLeft, scrollTop }) => ( - renderTable({ - width, - height, - onScroll, - scrollLeft, - scrollTop, - ...props, - }) - )} - - )} - -); - -export default withStyles(styles)(renderAutoSizeTable); diff --git a/ui/src/components/Table/styles.css b/ui/src/components/Table/styles.css deleted file mode 100644 index e6bdc175ba..0000000000 --- a/ui/src/components/Table/styles.css +++ /dev/null @@ -1,27 +0,0 @@ -.frozenCells { - background-color: #fff; - overflow: hidden !important; -} - -.frozenCells, -.cells { - width: 100%; -} - -.frozenCell { - font-weight: bold; -} - -.frozenCell -.cell { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: baseline; - text-align: left; - padding: 0 1em 0 0; - white-space: nowrap; - overflow: hidden; -} diff --git a/ui/src/components/VisualiseIcon/index.js b/ui/src/components/VisualiseIcon/index.js index 10ab676434..3a5809a906 100644 --- a/ui/src/components/VisualiseIcon/index.js +++ b/ui/src/components/VisualiseIcon/index.js @@ -127,6 +127,7 @@ const VisualiseIcon = ({ [styles.visualisationSmall]: isSmall, }); + // TODO: alt should be mapped from image src, not type return ( { - const classes = classNames({ - [styles.visualisationIcon]: true, - [styles.active]: active, - }); - - return ( -
- -
{getTitle(type)}
-
- ); -}; - -VisualiseIconWithTitle.propTypes = { - type: PropTypes.string.isRequired, - active: PropTypes.bool.isRequired, - onClick: PropTypes.func.isRequired, -}; - -export const StyledVisualiseIconWithTitle = withStyles(styles)(VisualiseIconWithTitle); diff --git a/ui/src/containers/Visualisations/CustomBarChart/Editor.js b/ui/src/containers/Visualisations/CustomBarChart/Editor.js index 9eceab8ff0..2483d3aa9e 100644 --- a/ui/src/containers/Visualisations/CustomBarChart/Editor.js +++ b/ui/src/containers/Visualisations/CustomBarChart/Editor.js @@ -9,6 +9,8 @@ import AddQueryButton from '../components/AddQueryButton'; import BarChartGroupingLimitForm from '../components/BarChartGroupingLimitForm'; import DescriptionForm from '../components/DescriptionForm'; import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; import SourceViewForm from '../components/SourceViewForm'; import StackedSwitch from '../components/StackedSwitch'; import TimezoneForm from '../components/TimezoneForm'; @@ -65,6 +67,24 @@ const Editor = ({ }); }, [id]); + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeBarChartGroupingLimit = useCallback((limit) => { updateModel({ schema: 'visualisation', @@ -134,6 +154,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + diff --git a/ui/src/containers/Visualisations/CustomColumnChart/Editor.js b/ui/src/containers/Visualisations/CustomColumnChart/Editor.js index f6ba76b7ee..828663d1f0 100644 --- a/ui/src/containers/Visualisations/CustomColumnChart/Editor.js +++ b/ui/src/containers/Visualisations/CustomColumnChart/Editor.js @@ -8,6 +8,8 @@ import VisualiseFilterForm from 'ui/containers/VisualiseFilterForm'; import AddQueryButton from '../components/AddQueryButton'; import DescriptionForm from '../components/DescriptionForm'; import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; import SourceViewForm from '../components/SourceViewForm'; import StackedSwitch from '../components/StackedSwitch'; import TimezoneForm from '../components/TimezoneForm'; @@ -62,6 +64,24 @@ const Editor = ({ }); }, [id]); + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeTimezone = useCallback((timezone) => { updateModel({ schema: 'visualisation', @@ -115,6 +135,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + void} props.onClick + */ +const Card = ({ + active, + onClick, +}) => ( + +); + +Card.propTypes = { + active: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, +}; + +export default React.memo(Card); diff --git a/ui/src/containers/Visualisations/CustomCounter/Editor.js b/ui/src/containers/Visualisations/CustomCounter/Editor.js new file mode 100644 index 0000000000..7c35aa6a62 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomCounter/Editor.js @@ -0,0 +1,122 @@ +import React, { useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Map } from 'immutable'; +import { Tab } from 'react-toolbox/lib/tabs'; +import Tabs from 'ui/components/Material/Tabs'; +import CounterAxesEditor from 'ui/containers/VisualiseForm/StatementsForm/AxesEditor/CounterAxesEditor'; +import VisualiseFilterForm from 'ui/containers/VisualiseFilterForm'; +import BenchmarkingEnabledSwitch from '../components/BenchmarkingEnabledSwitch'; +import DescriptionForm from '../components/DescriptionForm'; +import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import TimezoneForm from '../components/TimezoneForm'; +import Viewer from './Viewer'; + +/** + * @param {immutable.Map} props.model - visualisation model + * @param {string} props.orgTimezone + * @param {(args: object) => void} props.updateModel + */ +const Editor = ({ + model, + orgTimezone, + updateModel, +}) => { + const id = model.get('_id'); + const [tabIndex, setTabIndex] = useState(0); + + const onChangeDescription = useCallback((description) => { + updateModel({ + schema: 'visualisation', + id, + path: 'description', + value: description, + }); + }, [id]); + + const onChangeTimezone = useCallback((timezone) => { + updateModel({ + schema: 'visualisation', + id, + path: 'timezone', + value: timezone, + }); + }, [id]); + + const onChangePreviewPeriod = useCallback((previewPeriod) => { + updateModel({ + schema: 'visualisation', + id, + path: 'previewPeriod', + value: previewPeriod, + }); + }, [id]); + + const onChangeBenchmarkingEnabled = useCallback((benchmarkingEnabled) => { + updateModel({ + schema: 'visualisation', + id, + path: 'benchmarkingEnabled', + value: benchmarkingEnabled, + }); + }, [id]); + + return ( +
+
+
+ + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+ +
+ +
+
+
+ ); +}; + +Editor.propTypes = { + model: PropTypes.instanceOf(Map).isRequired, + orgTimezone: PropTypes.string.isRequired, + updateModel: PropTypes.func.isRequired, +}; + +export default Editor; diff --git a/ui/src/containers/Visualisations/CustomCounter/Viewer.js b/ui/src/containers/Visualisations/CustomCounter/Viewer.js new file mode 100644 index 0000000000..f0590e9fc2 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomCounter/Viewer.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import CounterResults from 'ui/containers/VisualiseResults/CounterResults'; + +/** + * @param {string} props.visualisationId + */ +const Viewer = ({ + visualisationId, +}) => ; + +Viewer.propTypes = { + visualisationId: PropTypes.string.isRequired, +}; + +export default React.memo(Viewer); diff --git a/ui/src/containers/Visualisations/CustomCounter/constants.js b/ui/src/containers/Visualisations/CustomCounter/constants.js new file mode 100644 index 0000000000..1385c41880 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomCounter/constants.js @@ -0,0 +1,4 @@ +import { COUNTER_IMAGE } from 'ui/components/VisualiseIcon/assets'; + +export const title = 'Counter'; +export const image = COUNTER_IMAGE; diff --git a/ui/src/containers/Visualisations/CustomCounter/index.js b/ui/src/containers/Visualisations/CustomCounter/index.js new file mode 100644 index 0000000000..9efaaff64d --- /dev/null +++ b/ui/src/containers/Visualisations/CustomCounter/index.js @@ -0,0 +1,24 @@ +import { compose } from 'recompose'; +import PropTypes from 'prop-types'; +import { Map } from 'immutable'; +import { connect } from 'react-redux'; +import { updateModel } from 'ui/redux/modules/models'; +import Editor from './Editor'; + +/** + * @param {immutable.Map} props.model - visualisation model + * @param {string} props.orgTimezone + */ +const CustomCounter = compose( + connect( + () => ({}), + { updateModel }, + ), +)(Editor); + +CustomCounter.propTypes = { + model: PropTypes.instanceOf(Map).isRequired, + orgTimezone: PropTypes.string.isRequired, +}; + +export default CustomCounter; diff --git a/ui/src/containers/Visualisations/CustomLineChart/Card.js b/ui/src/containers/Visualisations/CustomLineChart/Card.js new file mode 100644 index 0000000000..d1dd47d5a7 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomLineChart/Card.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import CustomCard from '../components/CustomCard'; +import { image, title } from './constants'; + +/** + * @param {boolean} props.active + * @param {() => void} props.onClick + */ +const Card = ({ + active, + onClick, +}) => ( + +); + +Card.propTypes = { + active: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, +}; + +export default React.memo(Card); diff --git a/ui/src/containers/Visualisations/CustomLineChart/Editor.js b/ui/src/containers/Visualisations/CustomLineChart/Editor.js new file mode 100644 index 0000000000..367ccea20f --- /dev/null +++ b/ui/src/containers/Visualisations/CustomLineChart/Editor.js @@ -0,0 +1,170 @@ +import React, { useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Map } from 'immutable'; +import { Tab } from 'react-toolbox/lib/tabs'; +import Tabs from 'ui/components/Material/Tabs'; +import LineAxesEditor from 'ui/containers/VisualiseForm/StatementsForm/AxesEditor/LineAxesEditor'; +import VisualiseFilterForm from 'ui/containers/VisualiseFilterForm'; +import AddQueryButton from '../components/AddQueryButton'; +import DescriptionForm from '../components/DescriptionForm'; +import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; +import SourceViewForm from '../components/SourceViewForm'; +import TimezoneForm from '../components/TimezoneForm'; +import Viewer from './Viewer'; + +/** + * @param {immutable.Map} props.model - visualisation model + * @param {string} props.orgTimezone + * @param {(args: object) => void} props.updateModel + */ +const Editor = ({ + model, + orgTimezone, + updateModel, +}) => { + const id = model.get('_id'); + const [tabIndex, setTabIndex] = useState(0); + + const onChangeDescription = useCallback((description) => { + updateModel({ + schema: 'visualisation', + id, + path: 'description', + value: description, + }); + }, [id]); + + const onClickAddQueryButton = useCallback(() => { + updateModel({ + schema: 'visualisation', + id, + path: 'filters', + value: model.get('filters').push(new Map()), + }); + }, [id, model.get('filters').hashCode()]); + + const onChangeSourceView = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'sourceView', + value: checked, + }); + }, [id]); + + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + + const onChangeTimezone = useCallback((timezone) => { + updateModel({ + schema: 'visualisation', + id, + path: 'timezone', + value: timezone, + }); + }, [id]); + + const onChangePreviewPeriod = useCallback((previewPeriod) => { + updateModel({ + schema: 'visualisation', + id, + path: 'previewPeriod', + value: previewPeriod, + }); + }, [id]); + + return ( +
+
+
+ + + + + + + + + {model.get('filters').count() < 5 && ( + + )} + + + + + + + + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + + + + + +
+
+ +
+
+ +
+ +
+ +
+
+
+ ); +}; + +Editor.propTypes = { + model: PropTypes.instanceOf(Map).isRequired, + orgTimezone: PropTypes.string.isRequired, + updateModel: PropTypes.func.isRequired, +}; + +export default Editor; diff --git a/ui/src/containers/Visualisations/CustomLineChart/Viewer.js b/ui/src/containers/Visualisations/CustomLineChart/Viewer.js new file mode 100644 index 0000000000..239c6f2a95 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomLineChart/Viewer.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import SourceResults from 'ui/containers/VisualiseResults/SourceResults'; +import LineChartResults from 'ui/containers/VisualiseResults/LineChartResults'; + +/** + * @param {string} props.visualisationId + * @param {boolean} props.showSourceView + */ +const Viewer = ({ + visualisationId, + showSourceView, +}) => { + if (showSourceView) { + return ; + } + return ; +}; + +Viewer.propTypes = { + visualisationId: PropTypes.string.isRequired, + showSourceView: PropTypes.bool.isRequired, +}; + +export default React.memo(Viewer); diff --git a/ui/src/containers/Visualisations/CustomLineChart/constants.js b/ui/src/containers/Visualisations/CustomLineChart/constants.js new file mode 100644 index 0000000000..0b431abd42 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomLineChart/constants.js @@ -0,0 +1,4 @@ +import { FREQUENCY_IMAGE } from 'ui/components/VisualiseIcon/assets'; + +export const title = 'Line'; +export const image = FREQUENCY_IMAGE; diff --git a/ui/src/containers/Visualisations/CustomLineChart/index.js b/ui/src/containers/Visualisations/CustomLineChart/index.js new file mode 100644 index 0000000000..6ba6481283 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomLineChart/index.js @@ -0,0 +1,24 @@ +import { compose } from 'recompose'; +import PropTypes from 'prop-types'; +import { Map } from 'immutable'; +import { connect } from 'react-redux'; +import { updateModel } from 'ui/redux/modules/models'; +import Editor from './Editor'; + +/** + * @param {immutable.Map} props.model - visualisation model + * @param {string} props.orgTimezone + */ +const CustomLineChart = compose( + connect( + () => ({}), + { updateModel }, + ), +)(Editor); + +CustomLineChart.propTypes = { + model: PropTypes.instanceOf(Map).isRequired, + orgTimezone: PropTypes.string.isRequired, +}; + +export default CustomLineChart; diff --git a/ui/src/containers/Visualisations/CustomPieChart/Card.js b/ui/src/containers/Visualisations/CustomPieChart/Card.js new file mode 100644 index 0000000000..d1dd47d5a7 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomPieChart/Card.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import CustomCard from '../components/CustomCard'; +import { image, title } from './constants'; + +/** + * @param {boolean} props.active + * @param {() => void} props.onClick + */ +const Card = ({ + active, + onClick, +}) => ( + +); + +Card.propTypes = { + active: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, +}; + +export default React.memo(Card); diff --git a/ui/src/containers/Visualisations/CustomPieChart/Editor.js b/ui/src/containers/Visualisations/CustomPieChart/Editor.js new file mode 100644 index 0000000000..fc89b7f4ae --- /dev/null +++ b/ui/src/containers/Visualisations/CustomPieChart/Editor.js @@ -0,0 +1,183 @@ +import React, { useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Map } from 'immutable'; +import { Tab } from 'react-toolbox/lib/tabs'; +import Tabs from 'ui/components/Material/Tabs'; +import PieAxesEditor from 'ui/containers/VisualiseForm/StatementsForm/AxesEditor/PieAxesEditor'; +import VisualiseFilterForm from 'ui/containers/VisualiseFilterForm'; +import AddQueryButton from '../components/AddQueryButton'; +import DescriptionForm from '../components/DescriptionForm'; +import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; +import SourceViewForm from '../components/SourceViewForm'; +import IsDonutSwitch from '../components/IsDonutSwitch'; +import TimezoneForm from '../components/TimezoneForm'; +import Viewer from './Viewer'; + + /** + * @param {immutable.Map} props.model - visualisation model + * @param {string} props.orgTimezone + * @param {(args: object) => void} props.updateModel + */ +const Editor = ({ + model, + orgTimezone, + updateModel, +}) => { + const id = model.get('_id'); + const [tabIndex, setTabIndex] = useState(0); + + const onChangeDescription = useCallback((description) => { + updateModel({ + schema: 'visualisation', + id, + path: 'description', + value: description, + }); + }, [id]); + + const onClickAddQueryButton = useCallback(() => { + updateModel({ + schema: 'visualisation', + id, + path: 'filters', + value: model.get('filters').push(new Map()), + }); + }, [id, model.get('filters').hashCode()]); + + const onChangeIsDonut = useCallback((isDonut) => { + updateModel({ + schema: 'visualisation', + id, + path: 'isDonut', + value: isDonut, + }); + }, [id]); + + const onChangeSourceView = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'sourceView', + value: checked, + }); + }, [id]); + + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + + const onChangeTimezone = useCallback((timezone) => { + updateModel({ + schema: 'visualisation', + id, + path: 'timezone', + value: timezone, + }); + }, [id]); + + const onChangePreviewPeriod = useCallback((previewPeriod) => { + updateModel({ + schema: 'visualisation', + id, + path: 'previewPeriod', + value: previewPeriod, + }); + }, [id]); + + return ( +
+
+
+ + + + + + + + + {model.get('filters').count() < 5 && ( + + )} + + + + + + + + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + + + + + + +
+
+ +
+
+ +
+ +
+ +
+
+
+ ); +}; + +Editor.propTypes = { + model: PropTypes.instanceOf(Map).isRequired, + orgTimezone: PropTypes.string.isRequired, + updateModel: PropTypes.func.isRequired, +}; + +export default Editor; diff --git a/ui/src/containers/Visualisations/CustomPieChart/Viewer.js b/ui/src/containers/Visualisations/CustomPieChart/Viewer.js new file mode 100644 index 0000000000..277f208724 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomPieChart/Viewer.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import SourceResults from 'ui/containers/VisualiseResults/SourceResults'; +import PieChartResults from 'ui/containers/VisualiseResults/PieChartResults'; + + /** + * @param {string} props.visualisationId + * @param {boolean} props.showSourceView + */ +const Viewer = ({ + visualisationId, + showSourceView, +}) => { + if (showSourceView) { + return ; + } + return ; +}; + +Viewer.propTypes = { + visualisationId: PropTypes.string.isRequired, + showSourceView: PropTypes.bool.isRequired, +}; + +export default React.memo(Viewer); diff --git a/ui/src/containers/Visualisations/CustomPieChart/constants.js b/ui/src/containers/Visualisations/CustomPieChart/constants.js new file mode 100644 index 0000000000..c1cef73075 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomPieChart/constants.js @@ -0,0 +1,4 @@ +import { PIE_IMAGE } from 'ui/components/VisualiseIcon/assets'; + +export const title = 'Pie'; +export const image = PIE_IMAGE; diff --git a/ui/src/containers/Visualisations/CustomPieChart/index.js b/ui/src/containers/Visualisations/CustomPieChart/index.js new file mode 100644 index 0000000000..b58566d9c0 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomPieChart/index.js @@ -0,0 +1,24 @@ +import { compose } from 'recompose'; +import PropTypes from 'prop-types'; +import { Map } from 'immutable'; +import { connect } from 'react-redux'; +import { updateModel } from 'ui/redux/modules/models'; +import Editor from './Editor'; + +/** + * @param {immutable.Map} props.model - visualisation model + * @param {string} props.orgTimezone + */ +const CustomPieChart = compose( + connect( + () => ({}), + { updateModel }, + ), +)(Editor); + +CustomPieChart.propTypes = { + model: PropTypes.instanceOf(Map).isRequired, + orgTimezone: PropTypes.string.isRequired, +}; + +export default CustomPieChart; diff --git a/ui/src/containers/Visualisations/CustomXvsYChart/Card.js b/ui/src/containers/Visualisations/CustomXvsYChart/Card.js new file mode 100644 index 0000000000..d1dd47d5a7 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomXvsYChart/Card.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import CustomCard from '../components/CustomCard'; +import { image, title } from './constants'; + +/** + * @param {boolean} props.active + * @param {() => void} props.onClick + */ +const Card = ({ + active, + onClick, +}) => ( + +); + +Card.propTypes = { + active: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, +}; + +export default React.memo(Card); diff --git a/ui/src/containers/Visualisations/CustomXvsYChart/Editor.js b/ui/src/containers/Visualisations/CustomXvsYChart/Editor.js new file mode 100644 index 0000000000..68fb833f18 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomXvsYChart/Editor.js @@ -0,0 +1,188 @@ +import React, { useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Map } from 'immutable'; +import { Tab } from 'react-toolbox/lib/tabs'; +import Tabs from 'ui/components/Material/Tabs'; +import ScatterAxesEditor from 'ui/containers/VisualiseForm/StatementsForm/AxesEditor/ScatterAxesEditor'; +import VisualiseFilterForm from 'ui/containers/VisualiseFilterForm'; +import AddQueryButton from '../components/AddQueryButton'; +import DescriptionForm from '../components/DescriptionForm'; +import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; +import SourceViewForm from '../components/SourceViewForm'; +import TimezoneForm from '../components/TimezoneForm'; +import TrendLinesSwitch from '../components/TrendLinesSwitch'; +import Viewer from './Viewer'; + + /** + * @param {immutable.Map} props.model - visualisation model + * @param {string} props.orgTimezone + * @param {(args: object) => void} props.updateModel + */ +const Editor = ({ + model, + orgTimezone, + updateModel, +}) => { + const id = model.get('_id'); + const [tabIndex, setTabIndex] = useState(0); + + const onChangeDescription = useCallback((description) => { + updateModel({ + schema: 'visualisation', + id, + path: 'description', + value: description, + }); + }, [id]); + + const onClickAddQueryButton = useCallback(() => { + updateModel({ + schema: 'visualisation', + id, + path: 'filters', + value: model.get('filters').push(new Map()), + }); + }, [id, model.get('filters').hashCode()]); + + const onChangeSourceView = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'sourceView', + value: checked, + }); + }, [id]); + + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + + const onChangeTimezone = useCallback((timezone) => { + updateModel({ + schema: 'visualisation', + id, + path: 'timezone', + value: timezone, + }); + }, [id]); + + const onChangeTrendLines = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'trendLines', + value: checked, + }); + }, [id]); + + const onChangePreviewPeriod = useCallback((previewPeriod) => { + updateModel({ + schema: 'visualisation', + id, + path: 'previewPeriod', + value: previewPeriod, + }); + }, [id]); + + return ( +
+
+
+ + + + + + + + + {model.get('filters').count() < 5 && ( + + )} + + + + + + + + + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + + { + !model.get('sourceView') && ( + + ) + } + + + + +
+
+ +
+
+ +
+ +
+ +
+
+
+ ); +}; + +Editor.propTypes = { + model: PropTypes.instanceOf(Map).isRequired, + orgTimezone: PropTypes.string.isRequired, + updateModel: PropTypes.func.isRequired, +}; + +export default Editor; diff --git a/ui/src/containers/Visualisations/CustomXvsYChart/Viewer.js b/ui/src/containers/Visualisations/CustomXvsYChart/Viewer.js new file mode 100644 index 0000000000..3cf8007771 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomXvsYChart/Viewer.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import SourceResults from 'ui/containers/VisualiseResults/SourceResults'; +import XvsYChartResults from 'ui/containers/VisualiseResults/XvsYChartResults'; + + /** + * @param {string} props.visualisationId + * @param {boolean} props.showSourceView + */ +const Viewer = ({ + visualisationId, + showSourceView, +}) => { + if (showSourceView) { + return ; + } + return ; +}; + +Viewer.propTypes = { + visualisationId: PropTypes.string.isRequired, + showSourceView: PropTypes.bool.isRequired, +}; + +export default React.memo(Viewer); diff --git a/ui/src/containers/Visualisations/CustomXvsYChart/constants.js b/ui/src/containers/Visualisations/CustomXvsYChart/constants.js new file mode 100644 index 0000000000..8a96ce1a61 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomXvsYChart/constants.js @@ -0,0 +1,4 @@ +import { XVSY_IMAGE } from 'ui/components/VisualiseIcon/assets'; + +export const title = 'Correlation'; +export const image = XVSY_IMAGE; diff --git a/ui/src/containers/Visualisations/CustomXvsYChart/index.js b/ui/src/containers/Visualisations/CustomXvsYChart/index.js new file mode 100644 index 0000000000..a18e2d4532 --- /dev/null +++ b/ui/src/containers/Visualisations/CustomXvsYChart/index.js @@ -0,0 +1,24 @@ +import { compose } from 'recompose'; +import PropTypes from 'prop-types'; +import { Map } from 'immutable'; +import { connect } from 'react-redux'; +import { updateModel } from 'ui/redux/modules/models'; +import Editor from './Editor'; + +/** + * @param {immutable.Map} props.model - visualisation model + * @param {string} props.orgTimezone + */ +const CustomXvsYChart = compose( + connect( + () => ({}), + { updateModel }, + ), +)(Editor); + +CustomXvsYChart.propTypes = { + model: PropTypes.instanceOf(Map).isRequired, + orgTimezone: PropTypes.string.isRequired, +}; + +export default CustomXvsYChart; diff --git a/ui/src/containers/Visualisations/TemplateActivityOverTime/Editor.js b/ui/src/containers/Visualisations/TemplateActivityOverTime/Editor.js index dd535f4512..367ccea20f 100644 --- a/ui/src/containers/Visualisations/TemplateActivityOverTime/Editor.js +++ b/ui/src/containers/Visualisations/TemplateActivityOverTime/Editor.js @@ -8,6 +8,8 @@ import VisualiseFilterForm from 'ui/containers/VisualiseFilterForm'; import AddQueryButton from '../components/AddQueryButton'; import DescriptionForm from '../components/DescriptionForm'; import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; import SourceViewForm from '../components/SourceViewForm'; import TimezoneForm from '../components/TimezoneForm'; import Viewer from './Viewer'; @@ -52,6 +54,24 @@ const Editor = ({ }); }, [id]); + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeTimezone = useCallback((timezone) => { updateModel({ schema: 'visualisation', @@ -100,6 +120,19 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + + { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeBarChartGroupingLimit = useCallback((limit) => { updateModel({ schema: 'visualisation', @@ -136,6 +156,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + diff --git a/ui/src/containers/Visualisations/TemplateCuratrInteractionsVsEngagement/Editor.js b/ui/src/containers/Visualisations/TemplateCuratrInteractionsVsEngagement/Editor.js index ec1a70386c..68fb833f18 100644 --- a/ui/src/containers/Visualisations/TemplateCuratrInteractionsVsEngagement/Editor.js +++ b/ui/src/containers/Visualisations/TemplateCuratrInteractionsVsEngagement/Editor.js @@ -8,6 +8,8 @@ import VisualiseFilterForm from 'ui/containers/VisualiseFilterForm'; import AddQueryButton from '../components/AddQueryButton'; import DescriptionForm from '../components/DescriptionForm'; import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; import SourceViewForm from '../components/SourceViewForm'; import TimezoneForm from '../components/TimezoneForm'; import TrendLinesSwitch from '../components/TrendLinesSwitch'; @@ -53,6 +55,24 @@ const Editor = ({ }); }, [id]); + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeTimezone = useCallback((timezone) => { updateModel({ schema: 'visualisation', @@ -111,6 +131,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + { !model.get('sourceView') && ( { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeTimezone = useCallback((timezone) => { updateModel({ schema: 'visualisation', @@ -115,6 +135,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeTimezone = useCallback((timezone) => { updateModel({ schema: 'visualisation', @@ -110,6 +130,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + diff --git a/ui/src/containers/Visualisations/TemplateCuratrUserEngagementLeaderboard/Editor.js b/ui/src/containers/Visualisations/TemplateCuratrUserEngagementLeaderboard/Editor.js index 8ed910e9c4..c5ec389a74 100644 --- a/ui/src/containers/Visualisations/TemplateCuratrUserEngagementLeaderboard/Editor.js +++ b/ui/src/containers/Visualisations/TemplateCuratrUserEngagementLeaderboard/Editor.js @@ -9,6 +9,8 @@ import AddQueryButton from '../components/AddQueryButton'; import BarChartGroupingLimitForm from '../components/BarChartGroupingLimitForm'; import DescriptionForm from '../components/DescriptionForm'; import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; import SourceViewForm from '../components/SourceViewForm'; import StackedSwitch from '../components/StackedSwitch'; import TimezoneForm from '../components/TimezoneForm'; @@ -65,6 +67,24 @@ const Editor = ({ }); }, [id]); + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeBarChartGroupingLimit = useCallback((limit) => { updateModel({ schema: 'visualisation', @@ -136,6 +156,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + diff --git a/ui/src/containers/Visualisations/TemplateMostActivePeople/Editor.js b/ui/src/containers/Visualisations/TemplateMostActivePeople/Editor.js index 9eceab8ff0..2483d3aa9e 100644 --- a/ui/src/containers/Visualisations/TemplateMostActivePeople/Editor.js +++ b/ui/src/containers/Visualisations/TemplateMostActivePeople/Editor.js @@ -9,6 +9,8 @@ import AddQueryButton from '../components/AddQueryButton'; import BarChartGroupingLimitForm from '../components/BarChartGroupingLimitForm'; import DescriptionForm from '../components/DescriptionForm'; import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; import SourceViewForm from '../components/SourceViewForm'; import StackedSwitch from '../components/StackedSwitch'; import TimezoneForm from '../components/TimezoneForm'; @@ -65,6 +67,24 @@ const Editor = ({ }); }, [id]); + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeBarChartGroupingLimit = useCallback((limit) => { updateModel({ schema: 'visualisation', @@ -134,6 +154,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + diff --git a/ui/src/containers/Visualisations/TemplateMostPopularActivities/Editor.js b/ui/src/containers/Visualisations/TemplateMostPopularActivities/Editor.js index 9eceab8ff0..2483d3aa9e 100644 --- a/ui/src/containers/Visualisations/TemplateMostPopularActivities/Editor.js +++ b/ui/src/containers/Visualisations/TemplateMostPopularActivities/Editor.js @@ -9,6 +9,8 @@ import AddQueryButton from '../components/AddQueryButton'; import BarChartGroupingLimitForm from '../components/BarChartGroupingLimitForm'; import DescriptionForm from '../components/DescriptionForm'; import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; import SourceViewForm from '../components/SourceViewForm'; import StackedSwitch from '../components/StackedSwitch'; import TimezoneForm from '../components/TimezoneForm'; @@ -65,6 +67,24 @@ const Editor = ({ }); }, [id]); + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeBarChartGroupingLimit = useCallback((limit) => { updateModel({ schema: 'visualisation', @@ -134,6 +154,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + diff --git a/ui/src/containers/Visualisations/TemplateMostPopularVerbs/Editor.js b/ui/src/containers/Visualisations/TemplateMostPopularVerbs/Editor.js index 8ed910e9c4..c5ec389a74 100644 --- a/ui/src/containers/Visualisations/TemplateMostPopularVerbs/Editor.js +++ b/ui/src/containers/Visualisations/TemplateMostPopularVerbs/Editor.js @@ -9,6 +9,8 @@ import AddQueryButton from '../components/AddQueryButton'; import BarChartGroupingLimitForm from '../components/BarChartGroupingLimitForm'; import DescriptionForm from '../components/DescriptionForm'; import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; import SourceViewForm from '../components/SourceViewForm'; import StackedSwitch from '../components/StackedSwitch'; import TimezoneForm from '../components/TimezoneForm'; @@ -65,6 +67,24 @@ const Editor = ({ }); }, [id]); + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeBarChartGroupingLimit = useCallback((limit) => { updateModel({ schema: 'visualisation', @@ -136,6 +156,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + diff --git a/ui/src/containers/Visualisations/TemplateWeekdaysActivity/Editor.js b/ui/src/containers/Visualisations/TemplateWeekdaysActivity/Editor.js index 02749ee6e7..116278e591 100644 --- a/ui/src/containers/Visualisations/TemplateWeekdaysActivity/Editor.js +++ b/ui/src/containers/Visualisations/TemplateWeekdaysActivity/Editor.js @@ -8,6 +8,8 @@ import VisualiseFilterForm from 'ui/containers/VisualiseFilterForm'; import AddQueryButton from '../components/AddQueryButton'; import DescriptionForm from '../components/DescriptionForm'; import PreviewPeriodPicker from '../components/PreviewPeriodPicker'; +import StatsTopOrBottomSwitch from '../components/StatsTopOrBottomSwitch'; +import ShowStatsSwitch from '../components/ShowStatsSwitch'; import SourceViewForm from '../components/SourceViewForm'; import StackedSwitch from '../components/StackedSwitch'; import TimezoneForm from '../components/TimezoneForm'; @@ -62,6 +64,24 @@ const Editor = ({ }); }, [id]); + const onChangeShowStats = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'showStats', + value: checked, + }); + }, [id]); + + const onChangeStatsAtBottom = useCallback((checked) => { + updateModel({ + schema: 'visualisation', + id, + path: 'statsAtBottom', + value: checked, + }); + }, [id]); + const onChangeTimezone = useCallback((timezone) => { updateModel({ schema: 'visualisation', @@ -115,6 +135,18 @@ const Editor = ({ sourceView={model.get('sourceView')} onChange={onChangeSourceView} /> + {model.get('sourceView') && ( + + )} + + {model.get('sourceView') && model.get('showStats') && ( + + )} + ; + case STATEMENTS: + return ; + case COUNTER: + return ; + case XVSY: + return ; + case FREQUENCY: + return ; + case PIE: + return ; case TEMPLATE_ACTIVITY_OVER_TIME: return ; case TEMPLATE_LAST_7_DAYS_STATEMENTS: @@ -81,10 +94,8 @@ const VisualisationViewer = ({ case TEMPLATE_CURATR_ACTIVITIES_WITH_MOST_COMMENTS: return ; default: - if (showSourceView) { - return ; - } - return ; + console.error(`VisualisationViewer.js does not support type "${type}"`) + return `Type "${type}" is not supported`; } }; diff --git a/ui/src/containers/Visualisations/components/ShowStatsSwitch.js b/ui/src/containers/Visualisations/components/ShowStatsSwitch.js new file mode 100644 index 0000000000..d28036c5a4 --- /dev/null +++ b/ui/src/containers/Visualisations/components/ShowStatsSwitch.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Switch from 'ui/components/Material/Switch'; + +/** + * @param {boolean} props.showStats + * @param {(showStats: boolean) => void} props.onChange + */ +const ShowStatsSwitch = ({ + showStats, + onChange, +}) => ( +
+ +
+); + +ShowStatsSwitch.propTypes = { + showStats: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default React.memo(ShowStatsSwitch); diff --git a/ui/src/containers/Visualisations/components/StatsTopOrBottomSwitch.js b/ui/src/containers/Visualisations/components/StatsTopOrBottomSwitch.js new file mode 100644 index 0000000000..e1ba1dd2f3 --- /dev/null +++ b/ui/src/containers/Visualisations/components/StatsTopOrBottomSwitch.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Switch from 'ui/components/Material/Switch'; + +/** + * @param {boolean} props.showStats + * @param {(statsAtBottom: boolean) => void} props.onChange + */ +const StatsTopOrBottomSwitch = ({ + statsAtBottom, + onChange, +}) => ( +
+ +
+); + +StatsTopOrBottomSwitch.propTypes = { + statsAtBottom: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default React.memo(StatsTopOrBottomSwitch); diff --git a/ui/src/containers/VisualiseForm/NewVisualisation/TypeEditor.js b/ui/src/containers/VisualiseForm/NewVisualisation/TypeEditor.js index 8d3137f135..8a141172dd 100644 --- a/ui/src/containers/VisualiseForm/NewVisualisation/TypeEditor.js +++ b/ui/src/containers/VisualiseForm/NewVisualisation/TypeEditor.js @@ -1,7 +1,6 @@ import React, { useCallback, useState } from 'react'; import PropTypes from 'prop-types'; import { Map } from 'immutable'; -import { StyledVisualiseIconWithTitle } from 'ui/components/VisualiseIcon'; import { LEADERBOARD, XVSY, @@ -10,8 +9,12 @@ import { COUNTER, PIE, } from 'lib/constants/visualise'; -import { default as CustomBarChartCard } from 'ui/containers/Visualisations/CustomBarChart/Card'; -import { default as CustomColumnChartCard } from 'ui/containers/Visualisations/CustomColumnChart/Card'; +import CustomBarChartCard from 'ui/containers/Visualisations/CustomBarChart/Card'; +import CustomColumnChartCard from 'ui/containers/Visualisations/CustomColumnChart/Card'; +import CustomCounterCard from 'ui/containers/Visualisations/CustomCounter/Card'; +import CustomLineChartCard from 'ui/containers/Visualisations/CustomLineChart/Card'; +import CustomPieChartCard from 'ui/containers/Visualisations/CustomPieChart/Card'; +import CustomXvsYChartCard from 'ui/containers/Visualisations/CustomXvsYChart/Card'; const getText = (type) => { switch (type) { @@ -49,15 +52,11 @@ const TypeEditor = ({ return (
- {/* [Refactor] Replace StyledVisualiseIconWithTitle with "Card" component - https://github.com/LearningLocker/enterprise/issues/991 - */} - @@ -65,18 +64,15 @@ const TypeEditor = ({ active={typeState === STATEMENTS} onClick={setSTATEMENTS} /> - - -
diff --git a/ui/src/containers/VisualiseForm/StatementsForm/AxesEditor/index.js b/ui/src/containers/VisualiseForm/StatementsForm/AxesEditor/index.js deleted file mode 100644 index 24cb02b87f..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/AxesEditor/index.js +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { - XVSY, - STATEMENTS, - FREQUENCY, - COUNTER, - PIE, -} from 'lib/constants/visualise'; -import ColumnAxesEditor from './ColumnAxesEditor'; -import LineAxesEditor from './LineAxesEditor'; -import ScatterAxesEditor from './ScatterAxesEditor'; -import CounterAxesEditor from './CounterAxesEditor'; -import PieAxesEditor from './PieAxesEditor'; - -// [Viz Refactor] TODO: Remove This component and put each OooooAxesEditor directly into Visualisation/.../Editor -const AxesEditor = ({ model, orgTimezone }) => { - switch (model.get('type')) { - case XVSY: return ; - case STATEMENTS: return ; - case FREQUENCY: return ; - case COUNTER: return ; - case PIE: return ; - default: return
renderDefault
; - } -}; - -export default AxesEditor; diff --git a/ui/src/containers/VisualiseForm/StatementsForm/Editor.js b/ui/src/containers/VisualiseForm/StatementsForm/Editor.js deleted file mode 100644 index a5b4a202eb..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/Editor.js +++ /dev/null @@ -1,130 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Map } from 'immutable'; -import { connect } from 'react-redux'; -import { Tab } from 'react-toolbox/lib/tabs'; -import withStyles from 'isomorphic-style-loader/lib/withStyles'; -import { COUNTER } from 'lib/constants/visualise'; -import { update$dteTimezone } from 'lib/helpers/update$dteTimezone'; -import { updateModel } from 'ui/redux/modules/models'; -import Tabs from 'ui/components/Material/Tabs'; -import SeriesEditor from './SeriesEditor'; -import AxesEditor from './AxesEditor'; -import OptionsEditor from './OptionsEditor'; -import styles from './styles.css'; - -// [Viz Refactor] TODO: Remove this component -class Editor extends Component { - static propTypes = { - model: PropTypes.instanceOf(Map), // visualisation model - orgTimezone: PropTypes.string.isRequired, - updateModel: PropTypes.func, - } - - state = { - tabIndex: 0, - } - - componentDidMount = () => { - const timezone = this.props.model.get('timezone') || this.props.orgTimezone; - this.updateQueriesIfUpdated(timezone); - } - - componentDidUpdate = (prevProps) => { - const prevTimezone = prevProps.model.get('timezone') || prevProps.orgTimezone; - const currentTimezone = this.props.model.get('timezone') || this.props.orgTimezone; - - if (prevTimezone !== currentTimezone) { - this.updateQueriesIfUpdated(currentTimezone); - } - } - - onChangeTab = tabIndex => this.setState({ tabIndex }) - - /** - * Update a model if its query is updated - */ - updateQueriesIfUpdated = (timezone) => { - // Values of these paths may have `{ $dte: ... }` sub queries. - const paths = ['filters', 'axesxQuery', 'axesyQuery']; - - paths.forEach((path) => { - const query = this.props.model.get(path) || new Map(); - const timezoneUpdated = update$dteTimezone(query, timezone); - - // Update visualisation.{path} when timezone offset in the filter query is changed - if (!timezoneUpdated.equals(query)) { - this.props.updateModel({ - schema: 'visualisation', - id: this.props.model.get('_id'), - path, - value: timezoneUpdated, - }); - } - }); - } - - onChangeDescription = (e) => { - this.props.updateModel({ - schema: 'visualisation', - id: this.props.model.get('_id'), - path: 'description', - value: e.target.value - }); - } - - // This method looks strange, but it is used on LearningLocker/enterprise - isSeriesType = () => false; - - render = () => { - // This if-conditional is used on LearningLocker/enterprise - if (this.isSeriesType()) { - return ( - - ); - } - - const isCounter = (this.props.model.get('type') === COUNTER); - - return ( -
-
- - -
- - - - - - - - - - - - - - -
- ); - } -} - -export default ( - withStyles(styles), - connect(() => ({}), { updateModel }) -)(Editor); diff --git a/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/BarEditor.js b/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/BarEditor.js deleted file mode 100644 index 7db2618e86..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/BarEditor.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import Switch from 'ui/components/Material/Switch'; -import { FIVE, TEN, FIFTEEN, TWENTY } from 'ui/utils/constants'; -import { compose, withHandlers } from 'recompose'; -import { setInMetadata } from 'ui/redux/modules/metadata'; -import { connect } from 'react-redux'; -import { updateModel } from 'ui/redux/modules/models'; - -const BarEditorComponent = ({ model, sourceViewHandler, barChartGroupingLimitHandler }) => ( -
- - -
-); - -const BarEditor = compose( - connect(() => ({}), { updateModel, setInMetadata }), - withHandlers({ - barChartGroupingLimitHandler: ({ updateModel: updateModelAction, model, setInMetadata: setInMetadataAction }) => (event) => { - updateModelAction({ - schema: 'visualisation', - id: model.get('_id'), - path: 'barChartGroupingLimit', - value: parseInt(event.target.value) - }); - - setInMetadataAction({ - schema: 'visualisation', - id: model.get('_id'), - path: ['activePage'], - value: 0 - }); - }, - sourceViewHandler: ({ updateModel: updateModelAction, model }) => () => { - updateModelAction({ - schema: 'visualisation', - id: model.get('_id'), - path: 'sourceView', - value: !model.get('sourceView') - }); - } - }) -)(BarEditorComponent); - -export default BarEditor; diff --git a/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/CounterEditor.js b/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/CounterEditor.js deleted file mode 100644 index effc039ca4..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/CounterEditor.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { compose, withHandlers } from 'recompose'; -import { updateModel } from 'ui/redux/modules/models'; -import Switch from 'ui/components/Material/Switch'; - -const CounterEditorComponent = ({ model, benchmarkingHandler }) => ( -
- -
- -
-
-); - - -const CounterEditor = compose( - connect(() => ({}), { updateModel }), - withHandlers({ - benchmarkingHandler: ({ updateModel: updateModelAction, model }) => () => { - updateModelAction({ - schema: 'visualisation', - id: model.get('_id'), - path: 'benchmarkingEnabled', - value: !model.get('benchmarkingEnabled') - }); - } - }) -)(CounterEditorComponent); - -export default CounterEditor; diff --git a/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/DefaultEditor.js b/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/DefaultEditor.js deleted file mode 100644 index b85be5583f..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/DefaultEditor.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { compose, withHandlers } from 'recompose'; -import { PIE } from 'lib/constants/visualise'; -import { updateModel } from 'ui/redux/modules/models'; -import Switch from 'ui/components/Material/Switch'; - -const DefaultEditorComponent = ({ model, sourceViewHandler, donutHandler }) => ( -
- - {!model.get('sourceView') && model.get('type') === PIE && } -
-); - -const DefaultEditor = compose( - connect(() => ({}), { updateModel }), - withHandlers({ - sourceViewHandler: ({ updateModel: updateModelAction, model }) => () => { - updateModelAction({ - schema: 'visualisation', - id: model.get('_id'), - path: 'sourceView', - value: !model.get('sourceView') - }); - }, - donutHandler: ({ updateModel: updateModelAction, model }) => () => { - updateModelAction({ - schema: 'visualisation', - id: model.get('_id'), - path: 'isDonut', - value: !model.get('isDonut') - }); - }, - }) -)(DefaultEditorComponent); - -export default DefaultEditor; diff --git a/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/XvsYOptionsEditor.js b/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/XvsYOptionsEditor.js deleted file mode 100644 index cf3ea9c470..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/XvsYOptionsEditor.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import Switch from 'ui/components/Material/Switch'; -import { updateModel } from 'ui/redux/modules/models'; -import { connect } from 'react-redux'; -import { compose, withHandlers } from 'recompose'; - -const XvsYOptionsEditorComponent = ({ model, trendLinesHandler, sourceViewHandler }) => ( -
- - {!model.get('sourceView') && } -
); - -export const XvsYOptionsEditor = compose( - connect(() => ({}), { updateModel }), - withHandlers({ - trendLinesHandler: ({ updateModel: updateModelAction, model }) => (value) => { - updateModelAction({ - schema: 'visualisation', - id: model.get('_id'), - path: 'trendLines', - value - }); - }, - sourceViewHandler: ({ updateModel: updateModelAction, model }) => () => { - updateModelAction({ - schema: 'visualisation', - id: model.get('_id'), - path: 'sourceView', - value: !model.get('sourceView') - }); - } - }) -)(XvsYOptionsEditorComponent); - -export default XvsYOptionsEditor; diff --git a/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/index.js b/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/index.js deleted file mode 100644 index 188daadd92..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/OptionsEditor/index.js +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import { XVSY, COUNTER, STATEMENTS, PIE, FREQUENCY } from 'lib/constants/visualise'; -import { TimezoneSelector, buildDefaultOptionLabel } from 'ui/components/TimezoneSelector'; -import CounterEditor from './CounterEditor'; -import DefaultEditor from './DefaultEditor'; -import XvsYOptionsEditor from './XvsYOptionsEditor'; - -// [Viz Refactor] TODO: Remove This component -const OptionsEditor = ({ model, orgTimezone, updateModel }) => { - const timezoneSelectorId = `Vis_${model._id}_TimezoneSelector`; - return ( -
- {(model.get('type') === XVSY) && } - {(model.get('type') === COUNTER) && } - {(model.get('type') === STATEMENTS) && } - {(model.get('type') === PIE) && } - {(model.get('type') === FREQUENCY) && } - - - updateModel({ - schema: 'visualisation', - id: model.get('_id'), - path: 'timezone', - value, - })} - defaultOption={{ - label: buildDefaultOptionLabel(orgTimezone), - value: orgTimezone, - }} /> -
- ); -}; - -export default OptionsEditor; diff --git a/ui/src/containers/VisualiseForm/StatementsForm/PreviewPeriodPicker.js b/ui/src/containers/VisualiseForm/StatementsForm/PreviewPeriodPicker.js deleted file mode 100644 index 11fbf2dc6a..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/PreviewPeriodPicker.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; - -import { - TODAY, - LAST_24_HOURS, - LAST_7_DAYS, - LAST_30_DAYS, - LAST_2_MONTHS, - LAST_6_MONTHS, - LAST_1_YEAR, - LAST_2_YEARS, -} from 'ui/utils/constants'; - -/** - * @params {{ - * visualisation: immutable.Map, - * onChange: (previewPeriod: string) => null, - * }} - */ -const PreviewPeriodPicker = ({ - visualisation, - onChange, -}) => ( - -); - -export default PreviewPeriodPicker; diff --git a/ui/src/containers/VisualiseForm/StatementsForm/ProjectedResults.js b/ui/src/containers/VisualiseForm/StatementsForm/ProjectedResults.js deleted file mode 100644 index 91e9f4a616..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/ProjectedResults.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { Map, List } from 'immutable'; -import { withVisualisationResults } from 'ui/utils/hocs'; -import { getResultsData } from 'ui/components/Charts/Chart'; -import { Table } from 'ui/components'; - -export default withVisualisationResults(({ visualisation, results }) => { - const filters = visualisation.get('filters', new List()); - const labels = filters.map(filter => filter.get('label')); - const entries = getResultsData(results)(labels).toList(); - return ( -
- - - ); -}); diff --git a/ui/src/containers/VisualiseForm/StatementsForm/SeriesEditor.js b/ui/src/containers/VisualiseForm/StatementsForm/SeriesEditor.js deleted file mode 100644 index b85f004584..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/SeriesEditor.js +++ /dev/null @@ -1,94 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Map } from 'immutable'; -import { connect } from 'react-redux'; -import { STATEMENTS, COUNTER, LEADERBOARD } from 'lib/constants/visualise'; -import Switch from 'ui/components/Material/Switch'; -import { updateModel } from 'ui/redux/modules/models'; -import VisualiseFilterForm from 'ui/containers/VisualiseFilterForm'; - -class SeriesEditor extends Component { - static propTypes = { - orgTimezone: PropTypes.string.isRequired, - model: PropTypes.instanceOf(Map), // visualisation - updateModel: PropTypes.func - } - - shouldComponentUpdate = nextProps => !( - this.props.model.equals(nextProps.model) - ) - - onAddQuery = () => { - this.props.updateModel({ - schema: 'visualisation', - id: this.props.model.get('_id'), - path: 'filters', - value: this.props.model.get('filters').push(new Map()), - }); - } - - getStacked = () => this.props.model.get('stacked', true) - - onChangeStackToggle = () => { - this.props.updateModel({ - schema: 'visualisation', - id: this.props.model.get('_id'), - path: 'stacked', - value: !this.getStacked(), - }); - } - - canStack = type => [LEADERBOARD, STATEMENTS].includes(type) - - canAddSeries = (type, filters) => - filters.count() < 5 && - ![STATEMENTS, COUNTER].includes(type) - - renderStackToggle = () => ( -
- - -
- -
-
- ) - - renderAddQueryButton = () => ( -
- -
- ) - - render = () => { - const { model, orgTimezone } = this.props; - - return ( -
- { - this.canAddSeries(model.get('type'), model.get('filters')) && - this.renderAddQueryButton() - } - - { - this.canStack(model.get('type')) && - this.renderStackToggle() - } - -
- ); - } -} - -export default connect(() => ({}), { updateModel })(SeriesEditor); diff --git a/ui/src/containers/VisualiseForm/StatementsForm/index.js b/ui/src/containers/VisualiseForm/StatementsForm/index.js deleted file mode 100644 index 6442e29bde..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/index.js +++ /dev/null @@ -1,63 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Map } from 'immutable'; -import { connect } from 'react-redux'; -import { updateModel } from 'ui/redux/modules/models'; -import VisualiseResults from 'ui/containers/VisualiseResults'; -import SourceResults from 'ui/containers/VisualiseResults/SourceResults'; -import Editor from './Editor'; -import PreviewPeriodPicker from './PreviewPeriodPicker'; - -class StatementsForm extends Component { - static propTypes = { - model: PropTypes.instanceOf(Map), - orgTimezone: PropTypes.string.isRequired, - updateModel: PropTypes.func, - } - - static defaultProps = { - model: new Map(), - } - - shouldComponentUpdate = nextProps => !( - this.props.model.equals(nextProps.model) - ) - - onChangePreviewPeriod = previewPeriod => this.props.updateModel({ - schema: 'visualisation', - id: this.props.model.get('_id'), - path: 'previewPeriod', - value: previewPeriod, - }) - - render = () => ( -
-
- -
- -
-
- -
- -
- { - this.props.model.get('sourceView') ? - : - - } -
-
-
- ) -} - -export default connect( - () => ({}), - { updateModel } -)(StatementsForm); diff --git a/ui/src/containers/VisualiseForm/StatementsForm/styles.css b/ui/src/containers/VisualiseForm/StatementsForm/styles.css deleted file mode 100644 index 235fbc1a73..0000000000 --- a/ui/src/containers/VisualiseForm/StatementsForm/styles.css +++ /dev/null @@ -1,3 +0,0 @@ -.tab section:focus { - outline: 0; -} diff --git a/ui/src/containers/VisualiseForm/index.js b/ui/src/containers/VisualiseForm/index.js index 1afa7d776d..3764e2c063 100644 --- a/ui/src/containers/VisualiseForm/index.js +++ b/ui/src/containers/VisualiseForm/index.js @@ -2,6 +2,10 @@ import React from 'react'; import { LEADERBOARD, STATEMENTS, + COUNTER, + XVSY, + FREQUENCY, + PIE, TEMPLATE_ACTIVITY_OVER_TIME, TEMPLATE_LAST_7_DAYS_STATEMENTS, TEMPLATE_MOST_ACTIVE_PEOPLE, @@ -17,6 +21,10 @@ import { } from 'lib/constants/visualise'; import CustomBarChart from 'ui/containers/Visualisations/CustomBarChart'; import CustomColumnChart from 'ui/containers/Visualisations/CustomColumnChart'; +import CustomCounter from 'ui/containers/Visualisations/CustomCounter'; +import CustomXvsYChart from 'ui/containers/Visualisations/CustomXvsYChart'; +import CustomLineChart from 'ui/containers/Visualisations/CustomLineChart'; +import CustomPieChart from 'ui/containers/Visualisations/CustomPieChart'; import TemplateActivityOverTime from 'ui/containers/Visualisations/TemplateActivityOverTime'; import TemplateLast7DaysStatements from 'ui/containers/Visualisations/TemplateLast7DaysStatements'; import TemplateMostActivePeople from 'ui/containers/Visualisations/TemplateMostActivePeople'; @@ -29,7 +37,6 @@ import TemplateCuratrLearnerInteractionsByDateAndVerb from 'ui/containers/Visual import TemplateCuratrUserEngagementLeaderboard from 'ui/containers/Visualisations/TemplateCuratrUserEngagementLeaderboard'; import TemplateCuratrProportionOfSocialInteractions from 'ui/containers/Visualisations/TemplateCuratrProportionOfSocialInteractions'; import TemplateCuratrActivitiesWithMostComments from 'ui/containers/Visualisations/TemplateCuratrActivitiesWithMostComments'; -import StatementsForm from './StatementsForm'; import NewVisualisation from './NewVisualisation'; const VisualiseForm = ({ model, orgTimezone }) => { @@ -39,6 +46,14 @@ const VisualiseForm = ({ model, orgTimezone }) => { return ; case STATEMENTS: return ; + case COUNTER: + return ; + case XVSY: + return ; + case FREQUENCY: + return ; + case PIE: + return ; case TEMPLATE_ACTIVITY_OVER_TIME: return ; case TEMPLATE_LAST_7_DAYS_STATEMENTS: @@ -64,7 +79,8 @@ const VisualiseForm = ({ model, orgTimezone }) => { case TEMPLATE_CURATR_ACTIVITIES_WITH_MOST_COMMENTS: return ; default: - return ; + console.error(`VisualiseForm/index.js does not support type ${model.get('type')}`) + return `type "${model.get('type')}" is not supported.`; } } diff --git a/ui/src/containers/VisualiseResults/SourceResults.js b/ui/src/containers/VisualiseResults/SourceResults.js index fcddd6ab24..da71ebd6f4 100644 --- a/ui/src/containers/VisualiseResults/SourceResults.js +++ b/ui/src/containers/VisualiseResults/SourceResults.js @@ -1,9 +1,8 @@ import React from 'react'; -import { compose } from 'recompose'; import { Map, OrderedMap } from 'immutable'; -import isString from 'lodash/isString'; -import withStyles from 'isomorphic-style-loader/lib/withStyles'; +import lodash from 'lodash'; import { + RESPONSE_ROWS_LIMIT, LEADERBOARD, XVSY, FREQUENCY, @@ -11,11 +10,12 @@ import { TEMPLATE_MOST_ACTIVE_PEOPLE, TEMPLATE_MOST_POPULAR_ACTIVITIES, TEMPLATE_MOST_POPULAR_VERBS, + TEMPLATE_CURATR_INTERACTIONS_VS_ENGAGEMENT, } from 'lib/constants/visualise'; import NoData from 'ui/components/Graphs/NoData'; +import ScrollableTable from 'ui/components/ScrollableTable'; import { withStatementsVisualisation } from 'ui/utils/hocs'; import { displayVerb, displayActivity } from 'ui/utils/xapi'; -import styles from './styles.css'; const moreThanOneSeries = tData => tData.first() !== undefined && tData.first().size > 1; @@ -57,7 +57,7 @@ const countSubColumns = (labels, tableData) => ); const formatKeyToFriendlyString = (key) => { - if (isString(key)) return key; + if (lodash.isString(key)) return key; if (Map.isMap(key)) { if (key.get('objectType')) { @@ -80,6 +80,7 @@ const getGroupAxisLabel = (visualisation) => { switch (visualisation.get('type')) { // Correlation Chart type case XVSY: + case TEMPLATE_CURATR_INTERACTIONS_VS_ENGAGEMENT: return visualisation.getIn(['axesgroup', 'searchString']) || 'Group'; // Bar Chart type case LEADERBOARD: @@ -100,6 +101,7 @@ const getValueAxisLabel = (index, visualisation) => { switch (visualisation.get('type')) { // Correlation Chart type case XVSY: + case TEMPLATE_CURATR_INTERACTIONS_VS_ENGAGEMENT: if (index === 0) { return visualisation.get('axesxLabel') || visualisation.getIn(['axesxValue', 'searchString']) || 'X Axis'; } @@ -115,17 +117,83 @@ const getValueAxisLabel = (index, visualisation) => { } }; -const formatNumber = (selectedAxes) => { - const count = selectedAxes.get('count'); +/** + * + * @param {any} count + * @returns {string} + */ +const formatNumber = (count) => { if (typeof count !== 'number') { return ''; } if (count % 1 !== 0) { return count.toFixed(2); } - return count; + return count.toString(); }; +/** + * @param {immutable.List>>>} allResults + * @returns {immutable.List>>} + */ +export const calcStats = allResults => + allResults.map(seriesResult => + seriesResult.map((axisResult) => { + /** + * The format of `axisResult`'s values is expected to be + * + * immutable.Map({ + * _id: any, + * count: number|null, + * model: any, + * }) + */ + if (axisResult.size === 0) { + return new Map({ + total: null, + avg: null, + min: null, + max: null, + rowCount: 0, + }); + } + const total = axisResult.reduce((acc, r) => acc + r.get('count', 0), 0); + return new Map({ + total, + avg: total / axisResult.size, + min: axisResult.minBy(r => r.get('count')).get('count', null), + max: axisResult.maxBy(r => r.get('count')).get('count', null), + rowCount: axisResult.size, + }); + }) + ); + +const keyLabels = [ + { key: 'total', label: 'Total' }, + { key: 'avg', label: 'Average' }, + { key: 'max', label: 'Max' }, + { key: 'min', label: 'Min' }, + { key: 'rowCount', label: 'Row Count' }, +]; + +const renderStatsTableRows = ({ + stats, + subColumnsCount, +}) => keyLabels.map(({ key, label }) => ( +
+ + { + stats.toArray().map((_, sIndex) => + [...Array(subColumnsCount).keys()].map(i => + + ) + ).flat() + } + +)); + const SourceResult = ({ getFormattedResults, results, @@ -141,50 +209,85 @@ const SourceResult = ({ return ; } + const showStats = visualisation.get('showStats', true); + const showStatsAtTop = showStats && !visualisation.get('statsAtBottom', true); + const statsAtBottom = showStats && visualisation.get('statsAtBottom', true); + + const stats = calcStats(formattedResults); + + // If result rows is RESPONSE_ROWS_LIMIT, the result might be limited. + const mightBeLimited = stats.some(s => s.some(a => a.get('rowCount') === RESPONSE_ROWS_LIMIT)); + return ( -
-
{label} + {formatNumber(stats.getIn([sIndex, i, key]))} +
- - {moreThanOneSeries(tableData) && - - )).valueSeq() - } - } - - - - { - tLabels.map(tLabel => - [...Array(subColumnsCount).keys()].map(k => - - ) - ).valueSeq() - } - - - {tableData.map((row, key) => ( - - +
+
+ +
+ {moreThanOneSeries(tableData) && ( + + + )) + } + + )} + + + { - tLabels.map(tLabel => - [...Array(subColumnsCount).keys()].map((k) => { - const v = row.getIn(['rowData', tLabel, k], new Map({ count: null })); - return ; - }) - ).valueSeq() + tLabels.toArray().map((_, i) => + [...Array(subColumnsCount).keys()].map(j => + + ) + ).flat() } - )).valueSeq()} - -
- { - tLabels.map(tLabel => ( - {tLabel}
{getGroupAxisLabel(visualisation)}{getValueAxisLabel(k, visualisation)}
{formatKeyToFriendlyString(row.get('model', key))}
+ { + tLabels.toArray().map((tLabel, i) => ( + + {tLabel} +
{getGroupAxisLabel(visualisation)}{formatNumber(v)} + {getValueAxisLabel(j, visualisation)} +
+ + {showStatsAtTop && ( + renderStatsTableRows({ stats, subColumnsCount }) + )} + + + + {tableData.toArray().map((row, key) => ( + + {formatKeyToFriendlyString(row.get('model', key))} + { + tLabels.toArray().map((tLabel, i) => + [...Array(subColumnsCount).keys()].map((j) => { + const v = row.getIn(['rowData', tLabel, j], new Map({ count: null })); + return {formatNumber(v.get('count'))}; + }) + ).flat() + } + + ))} + + + {statsAtBottom && ( + + {renderStatsTableRows({ stats, subColumnsCount })} + + )} + +
+ + {mightBeLimited && ( +
+ Totals calculated from the first {RESPONSE_ROWS_LIMIT.toLocaleString('en')} records +
+ )}
); }; -export default compose( - withStatementsVisualisation, - withStyles(styles), -)(SourceResult); +export default withStatementsVisualisation(SourceResult); diff --git a/ui/src/containers/VisualiseResults/SourceResults.spec.js b/ui/src/containers/VisualiseResults/SourceResults.spec.js index 986c0c086e..a9e3627f39 100644 --- a/ui/src/containers/VisualiseResults/SourceResults.spec.js +++ b/ui/src/containers/VisualiseResults/SourceResults.spec.js @@ -2,7 +2,7 @@ import React from 'react'; import ReactTestRenderer from 'react-test-renderer'; import { fromJS } from 'immutable'; import { withInsertCSS } from 'ui/utils/hocs'; -import SourceResults, { generateTableData } from './SourceResults'; +import SourceResults, { generateTableData, calcStats } from './SourceResults'; const data = fromJS([[ { @@ -35,3 +35,44 @@ test('SourceResults should render', () => { expect(criterion).toMatchSnapshot(); }); + +test('calcStats should calculate stats', () => { + const formattedResults = fromJS([ + [ + { + a: { _id: 'a', count: 123 }, + b: { _id: 'b', count: 12.3 }, + c: { _id: 'c', count: 0.003 }, + }, + {}, + ], + [ + { + a: { _id: 'a', count: -12.3 }, + b: { _id: 'b', count: 7.89 }, + c: { _id: 'c', count: -45.6 }, + d: { _id: 'd', count: 4.56 }, + }, + ], + ]); + + const actual = calcStats(formattedResults); + + expect(actual.getIn([0, 0, 'total'])).toEqual(135.303); + expect(actual.getIn([0, 0, 'avg'])).toEqual(45.101); + expect(actual.getIn([0, 0, 'min'])).toEqual(0.003); + expect(actual.getIn([0, 0, 'max'])).toEqual(123); + expect(actual.getIn([0, 0, 'rowCount'])).toEqual(3); + + expect(actual.getIn([0, 1, 'total'])).toEqual(null); + expect(actual.getIn([0, 1, 'avg'])).toEqual(null); + expect(actual.getIn([0, 1, 'min'])).toEqual(null); + expect(actual.getIn([0, 1, 'max'])).toEqual(null); + expect(actual.getIn([0, 1, 'rowCount'])).toEqual(0); + + expect(actual.getIn([1, 0, 'total'])).toEqual(-45.45); + expect(actual.getIn([1, 0, 'avg'])).toEqual(-11.3625); + expect(actual.getIn([1, 0, 'min'])).toEqual(-45.6); + expect(actual.getIn([1, 0, 'max'])).toEqual(7.89); + expect(actual.getIn([1, 0, 'rowCount'])).toEqual(4); +}); diff --git a/ui/src/containers/VisualiseResults/index.js b/ui/src/containers/VisualiseResults/index.js deleted file mode 100644 index ebf4ce133b..0000000000 --- a/ui/src/containers/VisualiseResults/index.js +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import withStyles from 'isomorphic-style-loader/lib/withStyles'; -import { compose, withProps } from 'recompose'; -import { - XVSY, - STATEMENTS, - FREQUENCY, - COUNTER, - PIE, -} from 'lib/constants/visualise'; -import { withModel } from 'ui/utils/hocs'; -import XvsYChartResults from 'ui/containers/VisualiseResults/XvsYChartResults'; -import LineChartResults from 'ui/containers/VisualiseResults/LineChartResults'; -import ColumnChartResults from 'ui/containers/VisualiseResults/ColumnChartResults'; -import CounterResults from 'ui/containers/VisualiseResults/CounterResults'; -import PieChartResults from 'ui/containers/VisualiseResults/PieChartResults'; - -import styles from './visualiseresults.css'; - -const VisualiseResults = ({ model }) => { - const visualisationType = model.get('type'); - const visualisationId = model.get('_id'); - - switch (visualisationType) { - case STATEMENTS: - return ; - case XVSY: - return ; - case COUNTER: - return ; - case PIE: - return ; - case FREQUENCY: - return ; - default: - return