diff --git a/lib/constants/dashboard.js b/lib/constants/dashboard.js index 17a99d86ef..13a8b7eb73 100644 --- a/lib/constants/dashboard.js +++ b/lib/constants/dashboard.js @@ -1,3 +1,7 @@ export const JWT_SECURED = 'JWT_SECURED'; export const ANY = 'ANY'; export const OFF = 'OFF'; + +export const BLANK_DASHBOARD = 'blankDashboard'; +export const STREAM_STARTER = 'streamStarter'; +export const GETTING_STARTED = 'gettingStarted'; diff --git a/lib/models/dashboard.js b/lib/models/dashboard.js index 140df0edcd..3e18e6218d 100644 --- a/lib/models/dashboard.js +++ b/lib/models/dashboard.js @@ -71,6 +71,7 @@ export const schema = new mongoose.Schema({ owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true }, isPublic: { type: Boolean, default: false }, hasBeenMigrated: { type: Boolean, default: false }, + type: { type: String }, }); schema.readScopes = keys(scopes.USER_SCOPES); diff --git a/ui/src/components/DropDownMenu/index.js b/ui/src/components/DropDownMenu/index.js index c75e6f64b1..f07c9b1d18 100644 --- a/ui/src/components/DropDownMenu/index.js +++ b/ui/src/components/DropDownMenu/index.js @@ -53,9 +53,9 @@ class DropDownMenu extends Component { {this.props.button} </span> {this.state.isOpen && - <ul className="dropdown-menu"> - {React.Children.map(this.props.children, child => ( - <li>{child}</li> + <ul className={`dropdown-menu ${this.props.customClass}`}> + {React.Children.map(this.props.children, (child, index) => ( + <li key={index}>{child}</li> ))} </ul> } diff --git a/ui/src/components/IconButton/CopyIconButton.js b/ui/src/components/IconButton/CopyIconButton.js index ec90d85f32..628fd3d6d1 100644 --- a/ui/src/components/IconButton/CopyIconButton.js +++ b/ui/src/components/IconButton/CopyIconButton.js @@ -7,7 +7,7 @@ import ConfirmModal from 'ui/components/Modal/ConfirmModal'; * @param {boolean} _.white - white button? default is btn-inverse * @param {() => void} _.onClickConfirm */ -const CopyIconButton = ({ message, white, onClickConfirm }) => { +const CopyIconButton = ({ message, white, onClickConfirm, textFormat }) => { const [isModalOpen, setModalOpen] = useState(false); const closeModal = useCallback(() => setModalOpen(false)); @@ -19,26 +19,36 @@ const CopyIconButton = ({ message, white, onClickConfirm }) => { onClickConfirm(); setModalOpen(false); }); + const renderConfirmModal = ( + <ConfirmModal + isOpen={isModalOpen} + title="Confirm copy" + message={<span>{message}</span>} + onConfirm={onConfirm} + onCancel={closeModal} /> + ); const className = white === true ? 'btn btn-default btn-sm flat-btn flat-white' : 'btn btn-sm btn-inverse'; return ( - <button - className={className} - title="Copy" - onClick={openModal} - style={{ width: '33px' }}> - <i className="icon ion-ios-copy" /> - - <ConfirmModal - isOpen={isModalOpen} - title="Confirm copy" - message={<span>{message}</span>} - onConfirm={onConfirm} - onCancel={closeModal} /> - </button> + textFormat ? ( + <span + onClick={openModal}> + Duplicate + {renderConfirmModal} + </span> + ) : ( + <button + className={className} + title="Copy" + onClick={openModal} + style={{ width: '33px' }}> + <i className="icon ion-ios-copy" /> + {renderConfirmModal} + </button> + ) ); }; diff --git a/ui/src/containers/Dashboard/index.js b/ui/src/containers/Dashboard/index.js index 18f567bffd..8fb7092426 100644 --- a/ui/src/containers/Dashboard/index.js +++ b/ui/src/containers/Dashboard/index.js @@ -1,38 +1,29 @@ import React, { Component } from 'react'; +import { isLoadingSelector } from 'ui/redux/modules/pagination'; import PropTypes from 'prop-types'; import * as _ from 'lodash'; import Scroll from 'react-scroll'; import { connect } from 'react-redux'; -import { actions as routerActions } from 'redux-router5'; +import { actions, routeNodeSelector } from 'redux-router5'; import { withProps, compose, lifecycle, withHandlers, mapProps } from 'recompose'; import { Map, List, is } from 'immutable'; import Input from 'ui/components/Material/Input'; import Spinner from 'ui/components/Spinner'; -import CopyIconButton from 'ui/components/IconButton/CopyIconButton'; import DashboardGrid from 'ui/containers/DashboardGrid'; import DashboardSharing from 'ui/containers/DashboardSharing'; -import DeleteButton from 'ui/containers/DeleteButton'; -import Owner from 'ui/containers/Owner'; -import PrivacyToggleButton from 'ui/containers/PrivacyToggleButton'; import WidgetVisualiseCreator from 'ui/containers/WidgetVisualiseCreator'; -import { withModel } from 'ui/utils/hocs'; -import { COPY_DASHBOARD } from 'ui/redux/modules/dashboard/copyDashboard'; -import { getVisualisationsFromDashboard } from 'ui/redux/modules/dashboard/selectors'; +import { withModels, withModel } from 'ui/utils/hocs'; import { loggedInUserId } from 'ui/redux/selectors'; import { activeOrgIdSelector } from 'ui/redux/modules/router'; -import { EditInputWrapper, Editor, EditWrapper } from 'ui/containers/Dashboard/styled'; - -const schema = 'dashboard'; +import { EditWrapper } from 'ui/containers/Dashboard/styled'; class Dashboard extends Component { static propTypes = { model: PropTypes.instanceOf(Map), - navigateTo: PropTypes.func, updateModel: PropTypes.func, saveModel: PropTypes.func, setMetadata: PropTypes.func, getMetadata: PropTypes.func, - doCopyDashboard: PropTypes.func, }; static defaultProps = { @@ -128,135 +119,134 @@ class Dashboard extends Component { }; render() { - const { model, organisationId, navigateTo, doCopyDashboard } = this.props; + const { model, backToDashboard } = this.props; if (!model.get('_id')) { return <Spinner />; } return ( - <div className="row"> - <Editor> - <EditWrapper> - <EditInputWrapper> + <div> + <header id="topbar"> + <div className="heading heading-light"> + <span className="pull-right open_panel_btn" > + <a + onClick={this.onClickAddWidget} + className="btn btn-primary btn-sm"> + <i className="ion ion-stats-bars" /> Add widget + </a> + + <button + className="btn btn-default btn-sm flat-btn flat-white" + title="Share" + onClick={this.toggleSharing} + style={{ + backgroundColor: this.props.getMetadata('isSharing') ? '#F5AB35' : null, + color: this.props.getMetadata('isSharing') ? 'white' : null, + marginRight: 0, + }}> + <i className="icon ion-android-share-alt" /> + </button> + </span> + <EditWrapper> + <a + onClick={backToDashboard} > + <i className="icon ion-chevron-left" /> + </a> <Input type="text" name="Title" - label="Enter title" value={model.get('title', ' ')} onChange={this.onTitleChange} - style={{ fontSize: '13px' }} /> - </EditInputWrapper> - - - - <a - onClick={this.onClickAddWidget} - className="btn btn-default btn-sm flat-btn flat-white"> - <i className="ion ion-stats-bars" /> Add widget - </a> - - <PrivacyToggleButton - white - id={model.get('_id')} - schema={schema} /> - - <CopyIconButton - message="This will copy the dashboard and visualisations. Are you sure?" - white - onClickConfirm={doCopyDashboard} /> - - <DeleteButton - white - id={model.get('_id')} - schema={schema} - onDeletedModel={() => - navigateTo('organisation.data.dashboards', { organisationId }) - } /> - - <button - className="btn btn-default btn-sm flat-btn flat-white" - title="Share" - onClick={this.toggleSharing} - style={{ - backgroundColor: this.props.getMetadata('isSharing') ? '#F5AB35' : null, - color: this.props.getMetadata('isSharing') ? 'white' : null - }}> - <i className="icon ion-android-share-alt" /> - </button> - - <span style={{ marginLeft: 'auto' }}> - <Owner model={model} /> - </span> - </EditWrapper> + style={{ fontSize: '18px', color: '#929292', padding: 0 }} /> + </EditWrapper> + </div> + </header> + <div className="row"> {this.props.getMetadata('isSharing') && - <div> + <div className="col-md-12"> <DashboardSharing shareable={model.get('shareable', new List())} id={model.get('_id')} /> </div> } - </Editor> - <div className="clearfix" /> + <div className="clearfix" /> - <WidgetVisualiseCreator - isOpened={this.state.widgetModalOpen} - model={model} - onClickClose={() => this.toggleWidgetModal()} - onChangeVisualisation={this.createPopulatedWidget} /> + <WidgetVisualiseCreator + isOpened={this.state.widgetModalOpen} + model={model} + onClickClose={() => this.toggleWidgetModal()} + onChangeVisualisation={this.createPopulatedWidget} /> - <DashboardGrid - widgets={model.get('widgets')} - onChange={this.onChangeWidgets} - onChangeTitle={this.onChangeWidgetTitle} - onChangeVisualisation={this.onChangeWidgetVisualisation} /> + <DashboardGrid + widgets={model.get('widgets')} + onChange={this.onChangeWidgets} + onChangeTitle={this.onChangeWidgetTitle} + onChangeVisualisation={this.onChangeWidgetVisualisation} /> + </div> </div> ); } } export default compose( - withProps(({ params, id }) => - ({ - schema, - id: id || params.dashboardId + connect( + state => ({ + isLoading: isLoadingSelector('dashboard', new Map())(state), + userId: loggedInUserId(state), + route: routeNodeSelector('organisation.dashboards')(state).route, + organisation: activeOrgIdSelector(state) + }), + { navigateTo: actions.navigateTo } + ), + withProps({ + schema: 'dashboard', + filter: new Map(), + first: 300, + }), + withModels, + withProps( + ({ route }) => ({ + id: route.name === 'organisation.data.dashboards.add' ? undefined : route.params.dashboardId }) ), withModel, + withProps( + ({ + id, + models, + model + }) => { + if (model.size === 0 && id) { + return ({ + modelsWithModel: models + }); + } + + return ({ + modelsWithModel: !id || models.has(id) ? models : models.reverse().set(id, model).reverse() + }); + } + ), lifecycle({ componentDidUpdate(previousProps) { - if (this.props.model.get('widgets').size > previousProps.model.get('widgets').size && window) { + if ( + this.props.model.get('widgets') && this.props.model.get('widgets').size && previousProps.size && + this.props.model.get('widgets').size > previousProps.model.get('widgets').size && window + ) { const scroll = Scroll.animateScroll; scroll.scrollToBottom({ smooth: true }); } } }), - connect( - state => ({ - organisationId: activeOrgIdSelector(state), - userId: loggedInUserId(state), - state, - }), - dispatch => ({ - navigateTo: () => dispatch(routerActions.navigateTo), - copyDashboard: ({ dashboard, visualisations, organisationId, userId }) => dispatch({ - type: COPY_DASHBOARD, - dispatch, - dashboard, - visualisations, - organisationId, - userId, - }), - }), - ), withHandlers({ - doCopyDashboard: ({ model, state, organisationId, copyDashboard, userId }) => () => copyDashboard({ - dashboard: model, - visualisations: getVisualisationsFromDashboard(model.get('_id'))(state), - organisationId, - userId, - }) + backToDashboard: ({ route, navigateTo }) => () => { + const organisationId = route.params.organisationId; + navigateTo('organisation.data.dashboards', { + organisationId + }); + } }), - mapProps(original => _.pick(original, ['model', 'navigateTo', 'updateModel', 'saveModel', 'setMetadata', 'getMetadata', 'doCopyDashboard'])), + mapProps(original => _.pick(original, ['model', 'updateModel', 'saveModel', 'setMetadata', 'getMetadata', 'backToDashboard'])), )(Dashboard); diff --git a/ui/src/containers/Dashboard/styled/index.js b/ui/src/containers/Dashboard/styled/index.js index 962ec764e3..b5ea0707e6 100644 --- a/ui/src/containers/Dashboard/styled/index.js +++ b/ui/src/containers/Dashboard/styled/index.js @@ -12,6 +12,18 @@ export const EditWrapper = styled.div` .btn { margin-bottom: auto; } + + &:global(.btn) { + margin-bottom: auto; + } + + div { + padding: 0 0 0 15px; + font-weight: 300; + color: #929292; + + font-family: Arial, sans-serif; + } `; export const EditInputWrapper = styled.div` diff --git a/ui/src/containers/DashboardCard/assets/blank-dashboard.png b/ui/src/containers/DashboardCard/assets/blank-dashboard.png new file mode 100644 index 0000000000..24534cf536 Binary files /dev/null and b/ui/src/containers/DashboardCard/assets/blank-dashboard.png differ diff --git a/ui/src/containers/DashboardCard/assets/getting-started.png b/ui/src/containers/DashboardCard/assets/getting-started.png new file mode 100644 index 0000000000..9cdf2d302e Binary files /dev/null and b/ui/src/containers/DashboardCard/assets/getting-started.png differ diff --git a/ui/src/containers/DashboardCard/assets/private.png b/ui/src/containers/DashboardCard/assets/private.png new file mode 100644 index 0000000000..f71af43c7b Binary files /dev/null and b/ui/src/containers/DashboardCard/assets/private.png differ diff --git a/ui/src/containers/DashboardCard/assets/stream-starter.png b/ui/src/containers/DashboardCard/assets/stream-starter.png new file mode 100644 index 0000000000..cd9bebb8d0 Binary files /dev/null and b/ui/src/containers/DashboardCard/assets/stream-starter.png differ diff --git a/ui/src/containers/DashboardCard/index.js b/ui/src/containers/DashboardCard/index.js new file mode 100644 index 0000000000..95df7cd329 --- /dev/null +++ b/ui/src/containers/DashboardCard/index.js @@ -0,0 +1,244 @@ +import React, { Component } from 'react'; +import Scroll from 'react-scroll'; +import * as _ from 'lodash'; +import classNames from 'classnames'; +import { connect } from 'react-redux'; +import withStyles from 'isomorphic-style-loader/lib/withStyles'; +import { loggedInUserId } from 'ui/redux/selectors'; +import { actions, routeNodeSelector } from 'redux-router5'; +import { activeOrgIdSelector } from 'ui/redux/modules/router'; +import { COPY_DASHBOARD } from 'ui/redux/modules/dashboard/copyDashboard'; +import { withProps, compose, lifecycle, withHandlers, mapProps } from 'recompose'; +import { getVisualisationsFromDashboard } from 'ui/redux/modules/dashboard/selectors'; +import { BLANK_DASHBOARD, STREAM_STARTER } from 'lib/constants/dashboard'; +import Owner from 'ui/containers/Owner'; +import { withModel } from 'ui/utils/hocs'; +import DeleteConfirm from 'ui/containers/DeleteConfirm'; +import DropDownMenu from 'ui/components/DropDownMenu'; +import EditConfirm from 'ui/containers/EditConfirm'; +import PrivacyToggleModal from 'ui/containers/PrivacyToggleModal'; +import ConfirmModal from 'ui/components/Modal/ConfirmModal'; +import blankDashboardIcon from './assets/blank-dashboard.png'; +import streamStarterIcon from './assets/stream-starter.png'; +import gettingStartedIcon from './assets/getting-started.png'; +import privateIcon from './assets/private.png'; +import styles from './styles.css'; + +const schema = 'dashboard'; + +export class DashboardCard extends Component { + constructor(props) { + super(props); + this.state = { + isEditOpen: null, + isDeleteOpen: null, + isDuplicateOpen: null, + isPrivacyOpen: null + }; + } + + openModal = (modal) => { + this.setState({ + [modal]: true + }); + } + + closeModal = (modal) => { + this.setState({ + [modal]: null + }); + } + + handlerDuplicate = () => { + const { doCopyDashboard } = this.props; + doCopyDashboard(); + this.closeModal('isDuplicateOpen'); + }; + + renderMenu = () => { + const { model } = this.props; + const { isEditOpen, isDeleteOpen, isDuplicateOpen, isPrivacyOpen } = this.state; + const isPublic = model.get('isPublic', false); + + return ( + <div> + <DropDownMenu + customClass={styles.dashboardCardMenu} + button={ + <a className={styles.menuButton}> + <i className="ion ion-navicon-round" /> + </a> + }> + <a + onClick={() => { this.openModal('isEditOpen'); }} + title="Edit Title"> + Edit Title + </a> + + <a + onClick={() => { this.openModal('isDuplicateOpen'); }} + title="Duplicate"> + Duplicate + </a> + + <a + onClick={() => { this.openModal('isPrivacyOpen'); }} + title="Privacy toggle"> + {isPublic + ? <span>Set to Private</span> + : <span>Set to Public</span>} + </a> + + <a + onClick={() => { this.openModal('isDeleteOpen'); }} + title="Delete button"> + Delete + </a> + </DropDownMenu> + + <EditConfirm + id={model.get('_id')} + schema={schema} + onClickClose={() => { this.closeModal('isEditOpen'); }} + isOpened={isEditOpen} /> + + <ConfirmModal + isOpen={isDuplicateOpen} + title="Confirm copy" + message={<span>This will duplicate the dashboard and visualisations. Are you sure?</span>} + onConfirm={this.handlerDuplicate} + onCancel={() => { this.closeModal('isDuplicateOpen'); }} /> + + <PrivacyToggleModal + id={model.get('_id')} + schema={schema} + onClickClose={() => { this.closeModal('isPrivacyOpen'); }} + isOpened={isPrivacyOpen} /> + + <DeleteConfirm + isOpened={isDeleteOpen} + schema={schema} + id={model.get('_id')} + onClickClose={() => { this.closeModal('isDeleteOpen'); }} /> + </div> + ); + } + + render = () => { + const { + model, + handleDashboard, + index + } = this.props; + const title = model.get('title', ' '); + const isPublic = model.get('isPublic', false); + + const iconType = () => { + const type = model.get('type', ' '); + + if (type === BLANK_DASHBOARD) { + return blankDashboardIcon; + } + if (type === STREAM_STARTER) { + return streamStarterIcon; + } + return gettingStartedIcon; + }; + + return ( + <div + className={styles.dashboardCard} > + <div className={styles.menuWrapper}> + {this.renderMenu()} + </div> + <div + key={index} + onClick={handleDashboard} + className={classNames(styles.card)}> + <img + className={styles.cardIcon} + src={iconType()} + alt={title} /> + + {!isPublic && ( + <span + className={styles.dashboardPrivateIcon} > + <img + src={privateIcon} + alt="private" /> + </span> + )} + + + <h4 + className={styles.cardTitle} + onClick={handleDashboard} > + {title} + </h4> + + <span style={{ marginLeft: 'auto' }}> + <Owner model={model} /> + </span> + </div> + </div> + ); + } +} + +export default compose( + withStyles(styles), + withProps(({ model }) => ({ + id: model.get('_id'), + })), + withModel, + lifecycle({ + componentDidUpdate(previousProps) { + if (this.props.model.get('widgets').size > previousProps.model.get('widgets').size && window) { + const scroll = Scroll.animateScroll; + scroll.scrollToBottom({ smooth: true }); + } + } + }), + connect( + state => ({ + route: routeNodeSelector('organisation.dashboards')(state).route, + organisationId: activeOrgIdSelector(state), + userId: loggedInUserId(state), + state + }), + dispatch => ({ + copyDashboard: ({ dashboard, visualisations, organisationId, userId }) => dispatch({ + type: COPY_DASHBOARD, + dispatch, + dashboard, + visualisations, + organisationId, + userId, + navigateTo: actions.navigateTo, + }), + }), + ), + withHandlers({ + doCopyDashboard: ({ model, state, organisationId, copyDashboard, userId }) => () => copyDashboard({ + dashboard: model, + visualisations: getVisualisationsFromDashboard(model.get('_id'))(state), + organisationId, + userId, + }), + handleDashboard: ({ + model, + navigateTo, + route, + }) => () => { + const organisationId = route.params.organisationId; + const dashboardId = model.get('_id'); + if (dashboardId) { + navigateTo('organisation.data.dashboards.id', { + organisationId, + dashboardId + }); + } + } + }), + mapProps(original => _.pick(original, ['model', 'handleDashboard', 'doCopyDashboard'])), +)(DashboardCard); diff --git a/ui/src/containers/DashboardCard/styles.css b/ui/src/containers/DashboardCard/styles.css new file mode 100644 index 0000000000..524e8a054e --- /dev/null +++ b/ui/src/containers/DashboardCard/styles.css @@ -0,0 +1,64 @@ + +.dashboardCard { + width: 100%; + padding: 0; + height: 100%; + margin-bottom: 0; + position: relative; + background-color: #fff; + border-radius: 0px; + -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); + box-sizing: border-box; + box-shadow: 0 0 0 1px rgba(200, 215, 225, 0.5), 0 1px 2px #e9eff3; +} + +.dashboardCardMenu { + right: 0; + left: auto; +} + +.dashboardPrivateIcon{ + vertical-align: top; + float: right; + margin-right: 30px; + display: block; + width: 26px; + height: 26px; + border-radius: 50%; + text-align: center; + background: rgba(196, 196, 196, 0.25); + padding-top: 2px; +} + +.card { + padding: 16px; + cursor: pointer; +} + +.cardTitle { + height: 100px; + overflow: hidden; +} + +.cardIcon { + max-width: 100px; + margin: -15px; +} + +.menuWrapper { + position: absolute; + right: 5px; + padding: 15px; +} + +.menuWrapper ul li a span { + width: 100%; + display: inline-block; +} + +.menuItem { + background: inherit; + border: none; + padding: 0; +} diff --git a/ui/src/containers/DashboardList/index.js b/ui/src/containers/DashboardList/index.js new file mode 100644 index 0000000000..85d0c66ec0 --- /dev/null +++ b/ui/src/containers/DashboardList/index.js @@ -0,0 +1,142 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Iterable } from 'immutable'; +import withStyles from 'isomorphic-style-loader/lib/withStyles'; +import Spinner from 'ui/components/Spinner'; +import { withModels } from 'ui/utils/hocs'; +import { loggedInUserId } from 'ui/redux/modules/auth'; +import { routeNodeSelector, actions } from 'redux-router5'; +import { compose, defaultProps, setPropTypes, withHandlers, withProps } from 'recompose'; +import { activeOrgIdSelector } from 'ui/redux/modules/router'; +import { isLoadingSelector } from 'ui/redux/modules/pagination'; +import DashboardCard from 'ui/containers/DashboardCard'; +import styles from './styles.css'; + +const enhance = compose( + setPropTypes({ + schema: PropTypes.string.isRequired, + isLoading: PropTypes.bool.isRequired, + hasMore: PropTypes.bool.isRequired, + models: PropTypes.instanceOf(Iterable).isRequired, + model: PropTypes.object, + fetchMore: PropTypes.func.isRequired, + getModelKey: PropTypes.func, + }), + defaultProps({ + getModelKey: model => model.get('_id', Math.random().toString()), + }), + withStyles(styles), + connect( + state => ({ + mod: state, + isLoading: isLoadingSelector('dashboard', new Map())(state), + userId: loggedInUserId(state), + route: routeNodeSelector('organisation.dashboards')(state).route, + organisation: activeOrgIdSelector(state) + }), + { navigateTo: actions.navigateTo } + ), + withProps({ + schema: 'dashboard', + filter: new Map(), + first: 12, + noItemsDisplay: 'No items.', + getModelKey: model => model.get('_id', Math.random().toString()) + }), + withModels, + withProps( + ({ route }) => ({ + id: route.name === 'organisation.data.dashboards.add' ? undefined : route.params.dashboardId + }) + ), + withProps( + ({ + id, + models, + model + }) => { + if (model.size === 0 && id) { + return ({ + modelsWithModel: models + }); + } + + return ({ + modelsWithModel: !id || models.has(id) ? models : models.reverse().set(id, model).reverse() + }); + } + ), + withHandlers({ + handleDashboard: ({ + navigateTo, + route, + }) => (dashboardId) => { + const organisationId = route.params.organisationId; + if (dashboardId) { + navigateTo('organisation.data.dashboards.id', { + organisationId, + dashboardId + }); + } + } + }), +); + +const renderLoadMoreButton = ({ isLoading, hasMore, fetchMore }) => + ( + <div style={{ marginTop: '20px' }}> + { isLoading ? ( + <Spinner /> + ) : ( + hasMore && + <button className="btn btn-default" onClick={fetchMore} > + Load more + </button> + )} + </div> + ); + +const ModelList = ({ + isLoading, + modelsWithModel, + hasMore, + schema, + fetchMore, + noItemsDisplay, + getModelKey, + route, + ...other +}) => { + if (modelsWithModel.size > 0) { + return ( + <div className={styles.dashboardList}> + { + modelsWithModel.map((model, index) => ( + <DashboardCard + {...other} + params={route.params} + key={getModelKey(model)} + index={index} + model={model} + schema={schema} /> + )).valueSeq() + } + + { isLoading + ? <Spinner /> + : renderLoadMoreButton({ isLoading, hasMore, fetchMore }) + } + </div> + ); + } else if (isLoading) { + return <Spinner />; + } + return ( + <div className="row"> + <div className="col-md-12"><h4>{ noItemsDisplay }</h4></div> + </div> + ); +}; + +export default enhance(ModelList); diff --git a/ui/src/containers/DashboardList/styles.css b/ui/src/containers/DashboardList/styles.css new file mode 100644 index 0000000000..fa1f81b2d1 --- /dev/null +++ b/ui/src/containers/DashboardList/styles.css @@ -0,0 +1,6 @@ +/* override styles for <a> tag defined in core.css*/ +.dashboardList { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-gap: 20px; +} diff --git a/ui/src/containers/DashboardTemplates/index.js b/ui/src/containers/DashboardTemplates/index.js index 856edbc096..04bd4dac7e 100644 --- a/ui/src/containers/DashboardTemplates/index.js +++ b/ui/src/containers/DashboardTemplates/index.js @@ -1,15 +1,26 @@ import React from 'react'; -import { CardList, Panel } from 'ui/containers/DashboardTemplates/styled'; +import { CardList, Panel, DashboardTitle } from 'ui/containers/DashboardTemplates/styled'; import BlankDashboard from './BlankDashboard'; import StreamStarter from './StreamStarter'; import GettingStarted from './GettingStarted'; -const DashboardTemplates = () => ( + +const DashboardTemplates = ({ handleClose }) => ( <Panel className={'panel panel-default'}> - <label htmlFor="dashboard-templates"> - Custom Templates - </label> + <DashboardTitle> + <label htmlFor="dashboard-templates"> + Add Dashboard + </label> + <div + className="close" + onClick={handleClose}> + <i className="ion-close-round" /> + </div> + </DashboardTitle> + <p> + Start by selecting a blank dashboard or choose a template + </p> <CardList id="dashboard-templates"> <BlankDashboard /> diff --git a/ui/src/containers/DashboardTemplates/styled/index.js b/ui/src/containers/DashboardTemplates/styled/index.js index 5dcf7d1e8d..695da47cf7 100644 --- a/ui/src/containers/DashboardTemplates/styled/index.js +++ b/ui/src/containers/DashboardTemplates/styled/index.js @@ -38,3 +38,12 @@ export const Card = styled.div` text-align: center; } `; + +export const DashboardTitle = styled.div` + display: flex; + justify-content: space-between; + + .close { + cursor: pointer; + } +`; diff --git a/ui/src/containers/Dashboards/index.js b/ui/src/containers/Dashboards/index.js index a58d99b24a..8560a127b7 100644 --- a/ui/src/containers/Dashboards/index.js +++ b/ui/src/containers/Dashboards/index.js @@ -1,52 +1,30 @@ -import React from 'react'; -import { isLoadingSelector } from 'ui/redux/modules/pagination'; -import Tabs from 'ui/components/Material/Tabs'; -import { Tab } from 'react-toolbox/lib/tabs'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; -import { Map } from 'immutable'; -import { routeNodeSelector, actions } from 'redux-router5'; -import { withProps, compose, withHandlers } from 'recompose'; -import { - withModels, - withModel -} from 'ui/utils/hocs'; +import { Map, fromJS } from 'immutable'; +import { routeNodeSelector } from 'redux-router5'; +import { withProps, compose } from 'recompose'; +import { withModels, withModel } from 'ui/utils/hocs'; import { loggedInUserId } from 'ui/redux/modules/auth'; -import Spinner from 'ui/components/Spinner'; -import Dashboard from 'ui/containers/Dashboard'; +import DashboardList from 'ui/containers/DashboardList'; import DashboardTemplates from 'ui/containers/DashboardTemplates'; -import { activeOrgIdSelector } from 'ui/redux/modules/router'; - -const ADD_ROUTE = 'add'; - -const StyledSpinner = () => ( - <div style={{ height: '60vh', display: 'flex' }}> - <Spinner /> - </div> -); - -const NoDashboards = () => ( - <div> - <h3>{"You don't have any dashboards yet! Add one to get started."}</h3> - <DashboardTemplates /> - </div> -); -const renderDashboard = params => (model, index) => ( - <Tab key={index} label={model.get('title', `Dashboard ${index + 1}`, '')}> - <Dashboard id={model.get('_id')} params={params} /> - </Tab> -); +const schema = 'dashboards'; +const DashboardLists = compose( + withProps({ + schema, + sort: fromJS({ createdAt: -1, _id: -1 }), + }), + withModels, + withModel, +)(DashboardList); const enhance = compose( connect( state => ({ - isLoading: isLoadingSelector('dashboard', new Map())(state), userId: loggedInUserId(state), route: routeNodeSelector('organisation.dashboards')(state).route, - organisation: activeOrgIdSelector(state) - }), - { navigateTo: actions.navigateTo } + }) ), withProps({ schema: 'dashboard', @@ -76,54 +54,42 @@ const enhance = compose( modelsWithModel: !id || models.has(id) ? models : models.reverse().set(id, model).reverse() }); } - ), - withHandlers({ - handleTabChange: ({ - models, - modelsWithModel, - navigateTo, - route, - }) => (tabIndex) => { - const organisationId = route.params.organisationId; - if (tabIndex === models.size) { - navigateTo('organisation.data.dashboards.add', { organisationId }); - return; - } - const selectedDashboard = modelsWithModel.toList().get(tabIndex); - navigateTo('organisation.data.dashboards.id', { - organisationId, - dashboardId: selectedDashboard.get('_id'), - }); - } - }), + ) ); -const Dashboards = ({ - handleTabChange, - isLoading, - modelsWithModel, - models, - route, -}) => { - if (isLoading) { - return <StyledSpinner />; - } +const Dashboards = () => { + const [addDashbord, setAddDashbord] = useState(false); - if (modelsWithModel.size === 0) { - return <NoDashboards />; - } - - const activeTab = (route.name === 'organisation.data.dashboards.add') ? - models.size : - modelsWithModel.toList().keyOf(modelsWithModel.get(route.params.dashboardId)); + const openDashboard = () => setAddDashbord(true); + const closeDashboard = () => setAddDashbord(false); return ( - <Tabs index={activeTab} onChange={handleTabChange}> - {modelsWithModel.map(renderDashboard(route.params)).valueSeq()} - <Tab label={ADD_ROUTE} > - <DashboardTemplates /> - </Tab> - </Tabs> + <div> + <header id="topbar"> + <div className="heading heading-light"> + <span className="pull-right open_panel_btn" > + <button + className="btn btn-primary btn-sm" + ref={() => {}} + onClick={openDashboard}> + <i className="ion ion-plus" /> Add Dashboard + </button> + </span> + Dashboards + </div> + </header> + <div className="row"> + {addDashbord && ( + <div className="col-md-12"> + <DashboardTemplates handleClose={closeDashboard} /> + </div> + )} + + <div className="col-md-12"> + <DashboardLists /> + </div> + </div> + </div> ); }; diff --git a/ui/src/containers/EditConfirm/index.js b/ui/src/containers/EditConfirm/index.js new file mode 100644 index 0000000000..e37a0f7bd5 --- /dev/null +++ b/ui/src/containers/EditConfirm/index.js @@ -0,0 +1,103 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Portal from 'react-portal'; +import { compose, withHandlers, setPropTypes } from 'recompose'; +import { withModel } from 'ui/utils/hocs'; +import Input from 'ui/components/Material/Input'; + +const enhance = compose( + setPropTypes({ + onClickClose: PropTypes.func.isRequired + }), + withModel, + withHandlers({ + onTitleChange: ({ updateModel }) => (value) => { + updateModel({ path: ['title'], value }); + } + }) +); + + +class EditConfirm extends Component { + static propTypes = { + model: PropTypes.instanceOf(Map).isRequired, + isOpened: PropTypes.bool.isRequired, + onClickClose: PropTypes.func.isRequired, + onTitleChange: PropTypes.func.isRequired, + }; + + constructor(props) { + super(props); + + const { model } = this.props; + this.state = { + title: model.get('title', ' '), + }; + } + + onTitleChange = (value) => { + this.setState({ + title: value + }); + }; + + handlerTitle = () => { + const { onTitleChange, onClickClose } = this.props; + const { title } = this.state; + onTitleChange(title); + onClickClose(); + }; + + render() { + const { isOpened, onClickClose } = this.props; + const { title } = this.state; + + return ( + <Portal isOpened={isOpened}> + <span> + <div className="modal animated fast fadeIn"> + <div className="modal-dialog"> + <div className="modal-content"> + + <div className="modal-header modal-header-bg"> + <button type="button" className="close" aria-label="Close" onClick={onClickClose}> + <span aria-hidden="true">×</span> + </button> + <h4 className="modal-title">Edit Dashboard Title</h4> + </div> + + <div + className="modal-body clearfix" + style={{ maxHeight: '500px', minWidth: '350px', overflow: 'auto', textAlign: 'center' }}> + <Input + type="text" + name="Title" + label="Dashboard Title" + value={title} + onChange={this.onTitleChange} + style={{ fontSize: '13px' }} /> + </div> + + <div className="modal-footer" style={{ textAlign: 'center' }}> + <a + onClick={this.handlerTitle} + className="btn btn-primary btn-sm"> + <i className="icon ion-checkmark" /> Confirm + </a> + <a + onClick={onClickClose} + className="btn btn-primary btn-sm"> + <i className="icon ion-close-round" /> Cancel + </a> + </div> + </div> + </div> + <div className="modal-backdrop" onClick={onClickClose.bind(null)} /> + </div> + </span> + </Portal> + ); + } +} + +export default enhance(EditConfirm); diff --git a/ui/src/containers/OrganisationHome/index.js b/ui/src/containers/OrganisationHome/index.js index f7cdb1cb09..07dea1a5b3 100644 --- a/ui/src/containers/OrganisationHome/index.js +++ b/ui/src/containers/OrganisationHome/index.js @@ -13,6 +13,12 @@ const renderPage = (routeName) => { const testRoute = startsWithSegment(routeName); // Data // + if (testRoute('organisation.data.dashboards.id')) { + return React.createElement(createAsyncComponent({ + loader: System.import('ui/containers/Dashboard') + })); + } + if (testRoute('organisation.data.dashboards')) { return React.createElement(createAsyncComponent({ loader: System.import('ui/containers/Dashboards') diff --git a/ui/src/containers/PrivacyToggleModal/index.js b/ui/src/containers/PrivacyToggleModal/index.js new file mode 100644 index 0000000000..7840ec0c67 --- /dev/null +++ b/ui/src/containers/PrivacyToggleModal/index.js @@ -0,0 +1,71 @@ +import React from 'react'; +import Portal from 'react-portal'; +import { compose, withHandlers } from 'recompose'; +import { withModel } from 'ui/utils/hocs'; + +const enhance = compose( + withModel, + withHandlers({ + togglePrivacy: ({ model, updateModel, onClickClose }) => (event) => { + const value = !model.get('isPublic', false); + updateModel({ path: ['isPublic'], value }); + onClickClose(); + event.stopPropagation(); + } + }) +); + +const render = ({ isOpened, togglePrivacy, onClickClose, model }) => { + const isPublic = model.get('isPublic', false); + + return ( + <Portal isOpened={isOpened}> + <span> + <div className="modal animated fast fadeIn"> + <div className="modal-dialog"> + <div className="modal-content"> + + <div className="modal-header modal-header-bg"> + <button type="button" className="close" aria-label="Close" onClick={onClickClose}> + <span aria-hidden="true">×</span> + </button> + <h4 className="modal-title"> + <span> + Confirm + {isPublic + ? <span> Set to Private</span> + : <span> Set to Public</span>} + </span> + </h4> + </div> + + <div + className="modal-body clearfix" + style={{ maxHeight: '500px', overflow: 'auto', textAlign: 'center' }}> + {isPublic + ? <span>This will hide the dashboard for others in your organisations. Are you sure?</span> + : <span>This will show the dashboard to others in your organisations. Are you sure?</span>} + </div> + + <div className="modal-footer" style={{ textAlign: 'center' }}> + <a + onClick={togglePrivacy} + className="btn btn-primary btn-sm"> + <i className="icon ion-checkmark" /> Confirm + </a> + <a + onClick={onClickClose} + className="btn btn-primary btn-sm"> + <i className="icon ion-close-round" /> Cancel + </a> + </div> + </div> + </div> + <div className="modal-backdrop" onClick={onClickClose.bind(null)} /> + </div> + </span> + </Portal> + ); +}; + +export default enhance(render); diff --git a/ui/src/containers/Widget/index.js b/ui/src/containers/Widget/index.js index b3f2080871..19cf49829f 100644 --- a/ui/src/containers/Widget/index.js +++ b/ui/src/containers/Widget/index.js @@ -245,7 +245,7 @@ class Widget extends Component { onChangeTitle={this.props.onChangeTitle} onChangeVisualisation={this.props.onChangeVisualisation} /> } - <DeleteConfirm isOpened={isDeleteOpen} {...delPopupProps} /> + <DeleteConfirm key={model.get('_id')} isOpened={isDeleteOpen} {...delPopupProps} /> </WidgetContent> </StyledWidget> ); diff --git a/ui/src/redux/modules/dashboard/blankDashboard.js b/ui/src/redux/modules/dashboard/blankDashboard.js index a21386e0b9..aea2e30328 100644 --- a/ui/src/redux/modules/dashboard/blankDashboard.js +++ b/ui/src/redux/modules/dashboard/blankDashboard.js @@ -10,6 +10,7 @@ function* createBlankDashboard({ userId, organisationId, dispatch }) { props: { owner: userId, title: 'Blank Dashboard', + type: 'blankDashboard', isExpanded: true, }, })); diff --git a/ui/src/redux/modules/dashboard/gettingStarted.js b/ui/src/redux/modules/dashboard/gettingStarted.js index e51a06e9c5..5066a45af3 100644 --- a/ui/src/redux/modules/dashboard/gettingStarted.js +++ b/ui/src/redux/modules/dashboard/gettingStarted.js @@ -67,6 +67,7 @@ function* createGettingStarted({ userId, organisationId, dispatch }) { props: { owner: userId, title: 'Getting Started', + type: 'gettingStarted', isExpanded: true, widgets: [ { x: 0, y: 0, w: 6, h: 8, visualisation: visualisationIds[0] }, diff --git a/ui/src/redux/modules/dashboard/streamStarter.js b/ui/src/redux/modules/dashboard/streamStarter.js index db532b7336..e24862d1f7 100644 --- a/ui/src/redux/modules/dashboard/streamStarter.js +++ b/ui/src/redux/modules/dashboard/streamStarter.js @@ -73,6 +73,7 @@ function* createStreamStarter({ userId, organisationId, dispatch }) { props: { owner: userId, title: 'Stream Starter', + type: 'streamStarter', isExpanded: true, widgets: [ { x: 0, y: 0, w: 6, h: 8, visualisation: visualisationIds[0] }, diff --git a/ui/src/static/core.css b/ui/src/static/core.css index bc747af935..b2d664fec5 100644 --- a/ui/src/static/core.css +++ b/ui/src/static/core.css @@ -3576,7 +3576,7 @@ tbody.collapse.in { position: absolute; top: 100%; left: 0; - z-index: 1000; + z-index: 10; display: none; float: left; min-width: 160px;