From 47c1c99d893517efc679ab29d675cc0bf44be8be Mon Sep 17 00:00:00 2001 From: Dominik Guzei Date: Thu, 11 Apr 2019 16:54:01 +0200 Subject: feat(App): Added Workspaces for all your daily routines 🥳 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge default and fetched feature configs * ignore intellij project files * basic setup for workspaces feature * define workspaces as premium feature * add workspaces menu item in settings dialog * basic setup of workspaces settings screen * fix eslint error * assign react key prop to workspace items * add styles for workspace table * setup logic to display workspace edit page * consolidate workspace feature for further development * prepare basic workspace edit form * add on enter key handler for form input component * add form for creating workspaces * small fixes * adds flow for deleting workspaces * stop tracking google analytics in components * pin gulp-sass-variables version to 1.1.1 * fix merge conflict * fix bug in form input library * improve workspace form setup * finish basic workspace settings * finish workspaces mvp * fix eslint issues * remove dev logs * detach service when underlying webview unmounts * disable no-param-reassign eslint rule * add workspace drawer * change workspace switch shortcuts to start with zero * add workspace drawer toggle menu item and shortcut * improve workspace switching ux * style add workspace icon in drawer like the sidebar icons * improve workspace drawer layout * add i18n messages for service loading and workspace switching * small fixes * add tooltip to add workspace button in drawer * add workspaces count badge in settings navigation * fix merge conflicts with latest develop * refactor state management for workspace feature * reset api requests when workspace feature is stopped * hide workspace feature if it is disabled * handle get workspaces request errors in the ui * show infobox when updating workspaces * indicate any server interaction with spinners and infoboxes * add analytic events for workspace actions * improve styling of workspace switch indicator * add workspace premium notice to dashboard * add workspace feature info in drawer for free users * add workspace premium badge in settings nav * fix premium workspace badge in settings menu for light theme * fix active workspaces settings premium badge in light theme * give upgrade account button a bit more padding * add open last used workspace logic * use mobx-localstorage directly in the store * fix wrong workspace tooltip shortcut in sidebar * fix bug in workspace feature initialization * show workspaces intro in drawer when user has none yet * fix issues for users that have workspace but downgraded to free * border radius for premium intro in workspace settings * close workspace drawer after clicking on a workspace * add hover effect for drawer workspace items * ensure drawer is open on workspace settings routes * add small text label for adding new workspace to drawer * make workspace settings list items taller * refactor workspace table css away from legacy styles * render workspace service list like services + toggle * change plus icon in workspace drawer to settings icon * autofocus create workspace input field * add css transition to drawer workspace item hover * fix drawer add workspace label styles * refactors workspace theme vars into object structure * improve contrast of workspace switching indicator * added generic pro badge component for settings nav * add premium badge to workspace drawer headline * add context menu for workspace drawer items * handle deleted services that are attached to workspaces --- src/features/delayApp/Component.js | 2 +- src/features/delayApp/index.js | 2 +- src/features/utils/FeatureStore.js | 21 ++ src/features/workspaces/actions.js | 26 ++ src/features/workspaces/api.js | 66 +++++ .../workspaces/components/CreateWorkspaceForm.js | 100 ++++++++ .../workspaces/components/EditWorkspaceForm.js | 189 ++++++++++++++ .../workspaces/components/WorkspaceDrawer.js | 246 ++++++++++++++++++ .../workspaces/components/WorkspaceDrawerItem.js | 137 ++++++++++ .../workspaces/components/WorkspaceItem.js | 45 ++++ .../components/WorkspaceServiceListItem.js | 75 ++++++ .../components/WorkspaceSwitchingIndicator.js | 91 +++++++ .../workspaces/components/WorkspacesDashboard.js | 195 +++++++++++++++ .../workspaces/containers/EditWorkspaceScreen.js | 60 +++++ .../workspaces/containers/WorkspacesScreen.js | 42 ++++ src/features/workspaces/index.js | 37 +++ src/features/workspaces/models/Workspace.js | 25 ++ src/features/workspaces/store.js | 276 +++++++++++++++++++++ 18 files changed, 1633 insertions(+), 2 deletions(-) create mode 100644 src/features/utils/FeatureStore.js create mode 100644 src/features/workspaces/actions.js create mode 100644 src/features/workspaces/api.js create mode 100644 src/features/workspaces/components/CreateWorkspaceForm.js create mode 100644 src/features/workspaces/components/EditWorkspaceForm.js create mode 100644 src/features/workspaces/components/WorkspaceDrawer.js create mode 100644 src/features/workspaces/components/WorkspaceDrawerItem.js create mode 100644 src/features/workspaces/components/WorkspaceItem.js create mode 100644 src/features/workspaces/components/WorkspaceServiceListItem.js create mode 100644 src/features/workspaces/components/WorkspaceSwitchingIndicator.js create mode 100644 src/features/workspaces/components/WorkspacesDashboard.js create mode 100644 src/features/workspaces/containers/EditWorkspaceScreen.js create mode 100644 src/features/workspaces/containers/WorkspacesScreen.js create mode 100644 src/features/workspaces/index.js create mode 100644 src/features/workspaces/models/Workspace.js create mode 100644 src/features/workspaces/store.js (limited to 'src/features') diff --git a/src/features/delayApp/Component.js b/src/features/delayApp/Component.js index ff84510e8..ff0f1f2f8 100644 --- a/src/features/delayApp/Component.js +++ b/src/features/delayApp/Component.js @@ -38,7 +38,7 @@ export default @inject('actions') @injectSheet(styles) @observer class DelayApp state = { countdown: config.delayDuration, - } + }; countdownInterval = null; diff --git a/src/features/delayApp/index.js b/src/features/delayApp/index.js index abc8274cf..67f0fc5e6 100644 --- a/src/features/delayApp/index.js +++ b/src/features/delayApp/index.js @@ -55,7 +55,7 @@ export default function init(stores) { setVisibility(true); gaPage('/delayApp'); - gaEvent('delayApp', 'show', 'Delay App Feature'); + gaEvent('DelayApp', 'show', 'Delay App Feature'); timeLastDelay = moment(); shownAfterLaunch = true; diff --git a/src/features/utils/FeatureStore.js b/src/features/utils/FeatureStore.js new file mode 100644 index 000000000..66b66a104 --- /dev/null +++ b/src/features/utils/FeatureStore.js @@ -0,0 +1,21 @@ +import Reaction from '../../stores/lib/Reaction'; + +export class FeatureStore { + _actions = null; + + _reactions = null; + + _listenToActions(actions) { + if (this._actions) this._actions.forEach(a => a[0].off(a[1])); + this._actions = []; + actions.forEach(a => this._actions.push(a)); + this._actions.forEach(a => a[0].listen(a[1])); + } + + _startReactions(reactions) { + if (this._reactions) this._reactions.forEach(r => r.stop()); + this._reactions = []; + reactions.forEach(r => this._reactions.push(new Reaction(r))); + this._reactions.forEach(r => r.start()); + } +} diff --git a/src/features/workspaces/actions.js b/src/features/workspaces/actions.js new file mode 100644 index 000000000..a85f8f57f --- /dev/null +++ b/src/features/workspaces/actions.js @@ -0,0 +1,26 @@ +import PropTypes from 'prop-types'; +import Workspace from './models/Workspace'; +import { createActionsFromDefinitions } from '../../actions/lib/actions'; + +export const workspaceActions = createActionsFromDefinitions({ + edit: { + workspace: PropTypes.instanceOf(Workspace).isRequired, + }, + create: { + name: PropTypes.string.isRequired, + }, + delete: { + workspace: PropTypes.instanceOf(Workspace).isRequired, + }, + update: { + workspace: PropTypes.instanceOf(Workspace).isRequired, + }, + activate: { + workspace: PropTypes.instanceOf(Workspace).isRequired, + }, + deactivate: {}, + toggleWorkspaceDrawer: {}, + openWorkspaceSettings: {}, +}, PropTypes.checkPropTypes); + +export default workspaceActions; diff --git a/src/features/workspaces/api.js b/src/features/workspaces/api.js new file mode 100644 index 000000000..0ec20c9ea --- /dev/null +++ b/src/features/workspaces/api.js @@ -0,0 +1,66 @@ +import { pick } from 'lodash'; +import { sendAuthRequest } from '../../api/utils/auth'; +import { API, API_VERSION } from '../../environment'; +import Request from '../../stores/lib/Request'; +import Workspace from './models/Workspace'; + +const debug = require('debug')('Franz:feature:workspaces:api'); + +export const workspaceApi = { + getUserWorkspaces: async () => { + const url = `${API}/${API_VERSION}/workspace`; + debug('getUserWorkspaces GET', url); + const result = await sendAuthRequest(url, { method: 'GET' }); + debug('getUserWorkspaces RESULT', result); + if (!result.ok) throw result; + const workspaces = await result.json(); + return workspaces.map(data => new Workspace(data)); + }, + + createWorkspace: async (name) => { + const url = `${API}/${API_VERSION}/workspace`; + const options = { + method: 'POST', + body: JSON.stringify({ name }), + }; + debug('createWorkspace POST', url, options); + const result = await sendAuthRequest(url, options); + debug('createWorkspace RESULT', result); + if (!result.ok) throw result; + return new Workspace(await result.json()); + }, + + deleteWorkspace: async (workspace) => { + const url = `${API}/${API_VERSION}/workspace/${workspace.id}`; + debug('deleteWorkspace DELETE', url); + const result = await sendAuthRequest(url, { method: 'DELETE' }); + debug('deleteWorkspace RESULT', result); + if (!result.ok) throw result; + return true; + }, + + updateWorkspace: async (workspace) => { + const url = `${API}/${API_VERSION}/workspace/${workspace.id}`; + const options = { + method: 'PUT', + body: JSON.stringify(pick(workspace, ['name', 'services'])), + }; + debug('updateWorkspace UPDATE', url, options); + const result = await sendAuthRequest(url, options); + debug('updateWorkspace RESULT', result); + if (!result.ok) throw result; + return new Workspace(await result.json()); + }, +}; + +export const getUserWorkspacesRequest = new Request(workspaceApi, 'getUserWorkspaces'); +export const createWorkspaceRequest = new Request(workspaceApi, 'createWorkspace'); +export const deleteWorkspaceRequest = new Request(workspaceApi, 'deleteWorkspace'); +export const updateWorkspaceRequest = new Request(workspaceApi, 'updateWorkspace'); + +export const resetApiRequests = () => { + getUserWorkspacesRequest.reset(); + createWorkspaceRequest.reset(); + deleteWorkspaceRequest.reset(); + updateWorkspaceRequest.reset(); +}; diff --git a/src/features/workspaces/components/CreateWorkspaceForm.js b/src/features/workspaces/components/CreateWorkspaceForm.js new file mode 100644 index 000000000..2c00ea63c --- /dev/null +++ b/src/features/workspaces/components/CreateWorkspaceForm.js @@ -0,0 +1,100 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import { Input, Button } from '@meetfranz/forms'; +import injectSheet from 'react-jss'; +import Form from '../../../lib/Form'; +import { required } from '../../../helpers/validation-helpers'; +import { gaEvent } from '../../../lib/analytics'; +import { GA_CATEGORY_WORKSPACES } from '../index'; + +const messages = defineMessages({ + submitButton: { + id: 'settings.workspace.add.form.submitButton', + defaultMessage: '!!!Create workspace', + }, + name: { + id: 'settings.workspace.add.form.name', + defaultMessage: '!!!Name', + }, +}); + +const styles = () => ({ + form: { + display: 'flex', + }, + input: { + flexGrow: 1, + marginRight: '10px', + }, + submitButton: { + height: 'inherit', + }, +}); + +@injectSheet(styles) @observer +class CreateWorkspaceForm extends Component { + static contextTypes = { + intl: intlShape, + }; + + static propTypes = { + classes: PropTypes.object.isRequired, + isSubmitting: PropTypes.bool.isRequired, + onSubmit: PropTypes.func.isRequired, + }; + + form = (() => { + const { intl } = this.context; + return new Form({ + fields: { + name: { + label: intl.formatMessage(messages.name), + placeholder: intl.formatMessage(messages.name), + value: '', + validators: [required], + }, + }, + }); + })(); + + submitForm() { + const { form } = this; + form.submit({ + onSuccess: async (f) => { + const { onSubmit } = this.props; + const values = f.values(); + onSubmit(values); + gaEvent(GA_CATEGORY_WORKSPACES, 'create', values.name); + }, + }); + } + + render() { + const { intl } = this.context; + const { classes, isSubmitting } = this.props; + const { form } = this; + return ( +
+ +
+ ); + } +} + +export default CreateWorkspaceForm; diff --git a/src/features/workspaces/components/EditWorkspaceForm.js b/src/features/workspaces/components/EditWorkspaceForm.js new file mode 100644 index 000000000..bba4485ff --- /dev/null +++ b/src/features/workspaces/components/EditWorkspaceForm.js @@ -0,0 +1,189 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import { Link } from 'react-router'; +import { Input, Button } from '@meetfranz/forms'; +import injectSheet from 'react-jss'; + +import Workspace from '../models/Workspace'; +import Service from '../../../models/Service'; +import Form from '../../../lib/Form'; +import { required } from '../../../helpers/validation-helpers'; +import WorkspaceServiceListItem from './WorkspaceServiceListItem'; +import Request from '../../../stores/lib/Request'; +import { gaEvent } from '../../../lib/analytics'; +import { GA_CATEGORY_WORKSPACES } from '../index'; + +const messages = defineMessages({ + buttonDelete: { + id: 'settings.workspace.form.buttonDelete', + defaultMessage: '!!!Delete workspace', + }, + buttonSave: { + id: 'settings.workspace.form.buttonSave', + defaultMessage: '!!!Save workspace', + }, + name: { + id: 'settings.workspace.form.name', + defaultMessage: '!!!Name', + }, + yourWorkspaces: { + id: 'settings.workspace.form.yourWorkspaces', + defaultMessage: '!!!Your workspaces', + }, + servicesInWorkspaceHeadline: { + id: 'settings.workspace.form.servicesInWorkspaceHeadline', + defaultMessage: '!!!Services in this Workspace', + }, +}); + +const styles = () => ({ + nameInput: { + height: 'auto', + }, + serviceList: { + height: 'auto', + }, +}); + +@injectSheet(styles) @observer +class EditWorkspaceForm extends Component { + static contextTypes = { + intl: intlShape, + }; + + static propTypes = { + classes: PropTypes.object.isRequired, + onDelete: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + services: PropTypes.arrayOf(PropTypes.instanceOf(Service)).isRequired, + workspace: PropTypes.instanceOf(Workspace).isRequired, + updateWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, + deleteWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, + }; + + form = this.prepareWorkspaceForm(this.props.workspace); + + componentWillReceiveProps(nextProps) { + const { workspace } = this.props; + if (workspace.id !== nextProps.workspace.id) { + this.form = this.prepareWorkspaceForm(nextProps.workspace); + } + } + + prepareWorkspaceForm(workspace) { + const { intl } = this.context; + return new Form({ + fields: { + name: { + label: intl.formatMessage(messages.name), + placeholder: intl.formatMessage(messages.name), + value: workspace.name, + validators: [required], + }, + services: { + value: workspace.services.slice(), + }, + }, + }); + } + + save(form) { + form.submit({ + onSuccess: async (f) => { + const { onSave } = this.props; + const values = f.values(); + onSave(values); + gaEvent(GA_CATEGORY_WORKSPACES, 'save'); + }, + onError: async () => {}, + }); + } + + delete() { + const { onDelete } = this.props; + onDelete(); + gaEvent(GA_CATEGORY_WORKSPACES, 'delete'); + } + + toggleService(service) { + const servicesField = this.form.$('services'); + const serviceIds = servicesField.value; + if (serviceIds.includes(service.id)) { + serviceIds.splice(serviceIds.indexOf(service.id), 1); + } else { + serviceIds.push(service.id); + } + servicesField.set(serviceIds); + } + + render() { + const { intl } = this.context; + const { + classes, + workspace, + services, + deleteWorkspaceRequest, + updateWorkspaceRequest, + } = this.props; + const { form } = this; + const workspaceServices = form.$('services').value; + const isDeleting = deleteWorkspaceRequest.isExecuting; + const isSaving = updateWorkspaceRequest.isExecuting; + return ( +
+
+ + + {intl.formatMessage(messages.yourWorkspaces)} + + + + + {workspace.name} + +
+
+
+ +
+

{intl.formatMessage(messages.servicesInWorkspaceHeadline)}

+
+ {services.map(s => ( + this.toggleService(s)} + /> + ))} +
+
+
+ {/* ===== Delete Button ===== */} +
+
+ ); + } +} + +export default EditWorkspaceForm; diff --git a/src/features/workspaces/components/WorkspaceDrawer.js b/src/features/workspaces/components/WorkspaceDrawer.js new file mode 100644 index 000000000..684e50dd0 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceDrawer.js @@ -0,0 +1,246 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import injectSheet from 'react-jss'; +import { defineMessages, FormattedHTMLMessage, intlShape } from 'react-intl'; +import { H1, Icon, ProBadge } from '@meetfranz/ui'; +import { Button } from '@meetfranz/forms/lib'; +import ReactTooltip from 'react-tooltip'; + +import WorkspaceDrawerItem from './WorkspaceDrawerItem'; +import { workspaceActions } from '../actions'; +import { GA_CATEGORY_WORKSPACES, workspaceStore } from '../index'; +import { gaEvent } from '../../../lib/analytics'; + +const messages = defineMessages({ + headline: { + id: 'workspaceDrawer.headline', + defaultMessage: '!!!Workspaces', + }, + allServices: { + id: 'workspaceDrawer.allServices', + defaultMessage: '!!!All services', + }, + workspacesSettingsTooltip: { + id: 'workspaceDrawer.workspacesSettingsTooltip', + defaultMessage: '!!!Workspaces settings', + }, + workspaceFeatureInfo: { + id: 'workspaceDrawer.workspaceFeatureInfo', + defaultMessage: '!!!Info about workspace feature', + }, + premiumCtaButtonLabel: { + id: 'workspaceDrawer.premiumCtaButtonLabel', + defaultMessage: '!!!Create your first workspace', + }, + reactivatePremiumAccount: { + id: 'workspaceDrawer.reactivatePremiumAccountLabel', + defaultMessage: '!!!Reactivate premium account', + }, + addNewWorkspaceLabel: { + id: 'workspaceDrawer.addNewWorkspaceLabel', + defaultMessage: '!!!add new workspace', + }, + premiumFeatureBadge: { + id: 'workspaceDrawer.proFeatureBadge', + defaultMessage: '!!!Premium feature', + }, +}); + +const styles = theme => ({ + drawer: { + background: theme.workspaces.drawer.background, + width: `${theme.workspaces.drawer.width}px`, + }, + headline: { + fontSize: '24px', + marginTop: '38px', + marginBottom: '25px', + marginLeft: theme.workspaces.drawer.padding, + }, + headlineProBadge: { + marginRight: 15, + }, + workspacesSettingsButton: { + float: 'right', + marginRight: theme.workspaces.drawer.padding, + marginTop: '2px', + }, + workspacesSettingsButtonIcon: { + fill: theme.workspaces.drawer.buttons.color, + '&:hover': { + fill: theme.workspaces.drawer.buttons.hoverColor, + }, + }, + workspaces: { + height: 'auto', + }, + premiumAnnouncement: { + padding: '20px', + paddingTop: '0', + height: 'auto', + }, + premiumCtaButton: { + marginTop: '20px', + width: '100%', + color: 'white !important', + }, + addNewWorkspaceLabel: { + height: 'auto', + color: theme.workspaces.drawer.buttons.color, + marginTop: 40, + textAlign: 'center', + '& > svg': { + fill: theme.workspaces.drawer.buttons.color, + }, + '& > span': { + fontSize: '13px', + marginLeft: 10, + position: 'relative', + top: -3, + }, + '&:hover': { + color: theme.workspaces.drawer.buttons.hoverColor, + '& > svg': { + fill: theme.workspaces.drawer.buttons.hoverColor, + }, + }, + }, +}); + +@injectSheet(styles) @observer +class WorkspaceDrawer extends Component { + static propTypes = { + classes: PropTypes.object.isRequired, + getServicesForWorkspace: PropTypes.func.isRequired, + onUpgradeAccountClick: PropTypes.func.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + componentDidMount() { + ReactTooltip.rebuild(); + } + + render() { + const { + classes, + getServicesForWorkspace, + onUpgradeAccountClick, + } = this.props; + const { intl } = this.context; + const { + activeWorkspace, + isSwitchingWorkspace, + nextWorkspace, + workspaces, + } = workspaceStore; + const actualWorkspace = isSwitchingWorkspace ? nextWorkspace : activeWorkspace; + return ( +
+

+ {workspaceStore.isPremiumUpgradeRequired && ( + + + + )} + {intl.formatMessage(messages.headline)} + { + workspaceActions.openWorkspaceSettings(); + gaEvent(GA_CATEGORY_WORKSPACES, 'settings', 'drawerHeadline'); + }} + data-tip={`${intl.formatMessage(messages.workspacesSettingsTooltip)}`} + > + + +

+ {workspaceStore.isPremiumUpgradeRequired ? ( +
+ + {workspaceStore.userHasWorkspaces ? ( +
+ ) : ( +
+ { + workspaceActions.deactivate(); + workspaceActions.toggleWorkspaceDrawer(); + gaEvent(GA_CATEGORY_WORKSPACES, 'switch', 'drawer'); + }} + services={getServicesForWorkspace(null)} + isActive={actualWorkspace == null} + /> + {workspaces.map(workspace => ( + { + if (actualWorkspace === workspace) return; + workspaceActions.activate({ workspace }); + workspaceActions.toggleWorkspaceDrawer(); + gaEvent(GA_CATEGORY_WORKSPACES, 'switch', 'drawer'); + }} + onContextMenuEditClick={() => workspaceActions.edit({ workspace })} + services={getServicesForWorkspace(workspace)} + /> + ))} +
{ + workspaceActions.openWorkspaceSettings(); + gaEvent(GA_CATEGORY_WORKSPACES, 'add', 'drawerAddLabel'); + }} + > + + + {intl.formatMessage(messages.addNewWorkspaceLabel)} + +
+
+ )} + +
+ ); + } +} + +export default WorkspaceDrawer; diff --git a/src/features/workspaces/components/WorkspaceDrawerItem.js b/src/features/workspaces/components/WorkspaceDrawerItem.js new file mode 100644 index 000000000..59a2144d3 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceDrawerItem.js @@ -0,0 +1,137 @@ +import { remote } from 'electron'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import injectSheet from 'react-jss'; +import classnames from 'classnames'; +import { defineMessages, intlShape } from 'react-intl'; + +const { Menu } = remote; + +const messages = defineMessages({ + noServicesAddedYet: { + id: 'workspaceDrawer.item.noServicesAddedYet', + defaultMessage: '!!!No services added yet', + }, + contextMenuEdit: { + id: 'workspaceDrawer.item.contextMenuEdit', + defaultMessage: '!!!edit', + }, +}); + +const styles = theme => ({ + item: { + height: '67px', + padding: `15px ${theme.workspaces.drawer.padding}px`, + borderBottom: `1px solid ${theme.workspaces.drawer.listItem.border}`, + transition: 'background-color 300ms ease-out', + '&:first-child': { + borderTop: `1px solid ${theme.workspaces.drawer.listItem.border}`, + }, + '&:hover': { + backgroundColor: theme.workspaces.drawer.listItem.hoverBackground, + }, + }, + isActiveItem: { + backgroundColor: theme.workspaces.drawer.listItem.activeBackground, + '&:hover': { + backgroundColor: theme.workspaces.drawer.listItem.activeBackground, + }, + }, + name: { + marginTop: '4px', + color: theme.workspaces.drawer.listItem.name.color, + }, + activeName: { + color: theme.workspaces.drawer.listItem.name.activeColor, + }, + services: { + display: 'block', + fontSize: '11px', + marginTop: '5px', + color: theme.workspaces.drawer.listItem.services.color, + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflow: 'hidden', + lineHeight: '15px', + }, + activeServices: { + color: theme.workspaces.drawer.listItem.services.active, + }, +}); + +@injectSheet(styles) @observer +class WorkspaceDrawerItem extends Component { + static propTypes = { + classes: PropTypes.object.isRequired, + isActive: PropTypes.bool.isRequired, + name: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + services: PropTypes.arrayOf(PropTypes.string).isRequired, + onContextMenuEditClick: PropTypes.func, + }; + + static defaultProps = { + onContextMenuEditClick: null, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + classes, + isActive, + name, + onClick, + onContextMenuEditClick, + services, + } = this.props; + const { intl } = this.context; + + const contextMenuTemplate = [{ + label: name, + enabled: false, + }, { + type: 'separator', + }, { + label: intl.formatMessage(messages.contextMenuEdit), + click: onContextMenuEditClick, + }]; + + const contextMenu = Menu.buildFromTemplate(contextMenuTemplate); + + return ( +
( + onContextMenuEditClick && contextMenu.popup(remote.getCurrentWindow()) + )} + > + + {name} + + + {services.length ? services.join(', ') : intl.formatMessage(messages.noServicesAddedYet)} + +
+ ); + } +} + +export default WorkspaceDrawerItem; diff --git a/src/features/workspaces/components/WorkspaceItem.js b/src/features/workspaces/components/WorkspaceItem.js new file mode 100644 index 000000000..cc4b1a3ba --- /dev/null +++ b/src/features/workspaces/components/WorkspaceItem.js @@ -0,0 +1,45 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { observer } from 'mobx-react'; +import injectSheet from 'react-jss'; + +import Workspace from '../models/Workspace'; + +const styles = theme => ({ + row: { + height: theme.workspaces.settings.listItems.height, + borderBottom: `1px solid ${theme.workspaces.settings.listItems.borderColor}`, + '&:hover': { + background: theme.workspaces.settings.listItems.hoverBgColor, + }, + }, + columnName: {}, +}); + +@injectSheet(styles) @observer +class WorkspaceItem extends Component { + static propTypes = { + classes: PropTypes.object.isRequired, + workspace: PropTypes.instanceOf(Workspace).isRequired, + onItemClick: PropTypes.func.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { classes, workspace, onItemClick } = this.props; + + return ( + + onItemClick(workspace)}> + {workspace.name} + + + ); + } +} + +export default WorkspaceItem; diff --git a/src/features/workspaces/components/WorkspaceServiceListItem.js b/src/features/workspaces/components/WorkspaceServiceListItem.js new file mode 100644 index 000000000..e05b21440 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceServiceListItem.js @@ -0,0 +1,75 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import injectSheet from 'react-jss'; +import classnames from 'classnames'; +import { Toggle } from '@meetfranz/forms'; + +import Service from '../../../models/Service'; +import ServiceIcon from '../../../components/ui/ServiceIcon'; + +const styles = theme => ({ + listItem: { + height: theme.workspaces.settings.listItems.height, + borderBottom: `1px solid ${theme.workspaces.settings.listItems.borderColor}`, + display: 'flex', + alignItems: 'center', + }, + serviceIcon: { + padding: theme.workspaces.settings.listItems.padding, + }, + toggle: { + height: 'auto', + margin: 0, + }, + label: { + padding: theme.workspaces.settings.listItems.padding, + flexGrow: 1, + }, + disabledLabel: { + color: theme.workspaces.settings.listItems.disabled.color, + }, +}); + +@injectSheet(styles) @observer +class WorkspaceServiceListItem extends Component { + static propTypes = { + classes: PropTypes.object.isRequired, + isInWorkspace: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, + service: PropTypes.instanceOf(Service).isRequired, + }; + + render() { + const { + classes, + isInWorkspace, + onToggle, + service, + } = this.props; + + return ( +
+ + + {service.name} + + +
+ ); + } +} + +export default WorkspaceServiceListItem; diff --git a/src/features/workspaces/components/WorkspaceSwitchingIndicator.js b/src/features/workspaces/components/WorkspaceSwitchingIndicator.js new file mode 100644 index 000000000..c4a800a7b --- /dev/null +++ b/src/features/workspaces/components/WorkspaceSwitchingIndicator.js @@ -0,0 +1,91 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import injectSheet from 'react-jss'; +import classnames from 'classnames'; +import { Loader } from '@meetfranz/ui'; +import { defineMessages, intlShape } from 'react-intl'; + +import { workspaceStore } from '../index'; + +const messages = defineMessages({ + switchingTo: { + id: 'workspaces.switchingIndicator.switchingTo', + defaultMessage: '!!!Switching to', + }, +}); + +const styles = theme => ({ + wrapper: { + display: 'flex', + alignItems: 'flex-start', + position: 'absolute', + transition: 'width 0.5s ease', + width: '100%', + marginTop: '20px', + }, + wrapperWhenDrawerIsOpen: { + width: `calc(100% - ${theme.workspaces.drawer.width}px)`, + }, + component: { + background: 'rgba(20, 20, 20, 0.4)', + padding: '10px 20px', + display: 'flex', + width: 'auto', + height: 'auto', + margin: [0, 'auto'], + borderRadius: 6, + alignItems: 'center', + zIndex: 200, + }, + spinner: { + width: 40, + height: 40, + marginRight: 10, + }, + message: { + fontSize: 16, + whiteSpace: 'nowrap', + color: theme.colorAppLoaderSpinner, + }, +}); + +@injectSheet(styles) @observer +class WorkspaceSwitchingIndicator extends Component { + static propTypes = { + classes: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { classes, theme } = this.props; + const { intl } = this.context; + const { isSwitchingWorkspace, isWorkspaceDrawerOpen, nextWorkspace } = workspaceStore; + if (!isSwitchingWorkspace) return null; + const nextWorkspaceName = nextWorkspace ? nextWorkspace.name : 'All services'; + return ( +
+
+ +

+ {`${intl.formatMessage(messages.switchingTo)} ${nextWorkspaceName}`} +

+
+
+ ); + } +} + +export default WorkspaceSwitchingIndicator; diff --git a/src/features/workspaces/components/WorkspacesDashboard.js b/src/features/workspaces/components/WorkspacesDashboard.js new file mode 100644 index 000000000..dd4381a15 --- /dev/null +++ b/src/features/workspaces/components/WorkspacesDashboard.js @@ -0,0 +1,195 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import injectSheet from 'react-jss'; +import { Infobox } from '@meetfranz/ui'; + +import Loader from '../../../components/ui/Loader'; +import WorkspaceItem from './WorkspaceItem'; +import CreateWorkspaceForm from './CreateWorkspaceForm'; +import Request from '../../../stores/lib/Request'; +import Appear from '../../../components/ui/effects/Appear'; +import { workspaceStore } from '../index'; +import PremiumFeatureContainer from '../../../components/ui/PremiumFeatureContainer'; + +const messages = defineMessages({ + headline: { + id: 'settings.workspaces.headline', + defaultMessage: '!!!Your workspaces', + }, + noServicesAdded: { + id: 'settings.workspaces.noWorkspacesAdded', + defaultMessage: '!!!You haven\'t added any workspaces yet.', + }, + workspacesRequestFailed: { + id: 'settings.workspaces.workspacesRequestFailed', + defaultMessage: '!!!Could not load your workspaces', + }, + tryReloadWorkspaces: { + id: 'settings.workspaces.tryReloadWorkspaces', + defaultMessage: '!!!Try again', + }, + updatedInfo: { + id: 'settings.workspaces.updatedInfo', + defaultMessage: '!!!Your changes have been saved', + }, + deletedInfo: { + id: 'settings.workspaces.deletedInfo', + defaultMessage: '!!!Workspace has been deleted', + }, + workspaceFeatureInfo: { + id: 'settings.workspaces.workspaceFeatureInfo', + defaultMessage: '!!!Info about workspace feature', + }, + workspaceFeatureHeadline: { + id: 'settings.workspaces.workspaceFeatureHeadline', + defaultMessage: '!!!Less is More: Introducing Franz Workspaces', + }, +}); + +const styles = theme => ({ + table: { + width: '100%', + '& td': { + padding: '10px', + }, + }, + createForm: { + height: 'auto', + }, + appear: { + height: 'auto', + }, + premiumAnnouncement: { + padding: '20px', + backgroundColor: '#3498db', + marginLeft: '-20px', + marginBottom: '20px', + height: 'auto', + color: 'white', + borderRadius: theme.borderRadius, + }, +}); + +@injectSheet(styles) @observer +class WorkspacesDashboard extends Component { + static propTypes = { + classes: PropTypes.object.isRequired, + getUserWorkspacesRequest: PropTypes.instanceOf(Request).isRequired, + createWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, + deleteWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, + updateWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, + onCreateWorkspaceSubmit: PropTypes.func.isRequired, + onWorkspaceClick: PropTypes.func.isRequired, + workspaces: MobxPropTypes.arrayOrObservableArray.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + classes, + getUserWorkspacesRequest, + createWorkspaceRequest, + deleteWorkspaceRequest, + updateWorkspaceRequest, + onCreateWorkspaceSubmit, + onWorkspaceClick, + workspaces, + } = this.props; + const { intl } = this.context; + return ( +
+
+

{intl.formatMessage(messages.headline)}

+
+
+ + {/* ===== Workspace updated info ===== */} + {updateWorkspaceRequest.wasExecuted && updateWorkspaceRequest.result && ( + + + {intl.formatMessage(messages.updatedInfo)} + + + )} + + {/* ===== Workspace deleted info ===== */} + {deleteWorkspaceRequest.wasExecuted && deleteWorkspaceRequest.result && ( + + + {intl.formatMessage(messages.deletedInfo)} + + + )} + + {workspaceStore.isPremiumUpgradeRequired && ( +
+

{intl.formatMessage(messages.workspaceFeatureHeadline)}

+

{intl.formatMessage(messages.workspaceFeatureInfo)}

+
+ )} + + + {/* ===== Create workspace form ===== */} +
+ +
+ {getUserWorkspacesRequest.isExecuting ? ( + + ) : ( + + {/* ===== Workspace could not be loaded error ===== */} + {getUserWorkspacesRequest.error ? ( + + {intl.formatMessage(messages.workspacesRequestFailed)} + + ) : ( + + {/* ===== Workspaces list ===== */} + + {workspaces.map(workspace => ( + onWorkspaceClick(w)} + /> + ))} + +
+ )} +
+ )} +
+
+
+ ); + } +} + +export default WorkspacesDashboard; diff --git a/src/features/workspaces/containers/EditWorkspaceScreen.js b/src/features/workspaces/containers/EditWorkspaceScreen.js new file mode 100644 index 000000000..248b40131 --- /dev/null +++ b/src/features/workspaces/containers/EditWorkspaceScreen.js @@ -0,0 +1,60 @@ +import React, { Component } from 'react'; +import { inject, observer } from 'mobx-react'; +import PropTypes from 'prop-types'; + +import ErrorBoundary from '../../../components/util/ErrorBoundary'; +import EditWorkspaceForm from '../components/EditWorkspaceForm'; +import ServicesStore from '../../../stores/ServicesStore'; +import Workspace from '../models/Workspace'; +import { workspaceStore } from '../index'; +import { deleteWorkspaceRequest, updateWorkspaceRequest } from '../api'; + +@inject('stores', 'actions') @observer +class EditWorkspaceScreen extends Component { + static propTypes = { + actions: PropTypes.shape({ + workspace: PropTypes.shape({ + delete: PropTypes.func.isRequired, + }), + }).isRequired, + stores: PropTypes.shape({ + services: PropTypes.instanceOf(ServicesStore).isRequired, + }).isRequired, + }; + + onDelete = () => { + const { workspaceBeingEdited } = workspaceStore; + const { actions } = this.props; + if (!workspaceBeingEdited) return null; + actions.workspaces.delete({ workspace: workspaceBeingEdited }); + }; + + onSave = (values) => { + const { workspaceBeingEdited } = workspaceStore; + const { actions } = this.props; + const workspace = new Workspace( + Object.assign({}, workspaceBeingEdited, values), + ); + actions.workspaces.update({ workspace }); + }; + + render() { + const { workspaceBeingEdited } = workspaceStore; + const { stores } = this.props; + if (!workspaceBeingEdited) return null; + return ( + + + + ); + } +} + +export default EditWorkspaceScreen; diff --git a/src/features/workspaces/containers/WorkspacesScreen.js b/src/features/workspaces/containers/WorkspacesScreen.js new file mode 100644 index 000000000..2ab565fa1 --- /dev/null +++ b/src/features/workspaces/containers/WorkspacesScreen.js @@ -0,0 +1,42 @@ +import React, { Component } from 'react'; +import { inject, observer } from 'mobx-react'; +import PropTypes from 'prop-types'; +import WorkspacesDashboard from '../components/WorkspacesDashboard'; +import ErrorBoundary from '../../../components/util/ErrorBoundary'; +import { workspaceStore } from '../index'; +import { + createWorkspaceRequest, + deleteWorkspaceRequest, + getUserWorkspacesRequest, + updateWorkspaceRequest, +} from '../api'; + +@inject('actions') @observer +class WorkspacesScreen extends Component { + static propTypes = { + actions: PropTypes.shape({ + workspace: PropTypes.shape({ + edit: PropTypes.func.isRequired, + }), + }).isRequired, + }; + + render() { + const { actions } = this.props; + return ( + + actions.workspaces.create(data)} + onWorkspaceClick={w => actions.workspaces.edit({ workspace: w })} + /> + + ); + } +} + +export default WorkspacesScreen; diff --git a/src/features/workspaces/index.js b/src/features/workspaces/index.js new file mode 100644 index 000000000..ad9023b8b --- /dev/null +++ b/src/features/workspaces/index.js @@ -0,0 +1,37 @@ +import { reaction } from 'mobx'; +import WorkspacesStore from './store'; +import { resetApiRequests } from './api'; + +const debug = require('debug')('Franz:feature:workspaces'); + +export const GA_CATEGORY_WORKSPACES = 'Workspaces'; + +export const workspaceStore = new WorkspacesStore(); + +export default function initWorkspaces(stores, actions) { + stores.workspaces = workspaceStore; + const { features } = stores; + + // Toggle workspace feature + reaction( + () => features.features.isWorkspaceEnabled, + (isEnabled) => { + if (isEnabled && !workspaceStore.isFeatureActive) { + debug('Initializing `workspaces` feature'); + workspaceStore.start(stores, actions); + } else if (workspaceStore.isFeatureActive) { + debug('Disabling `workspaces` feature'); + workspaceStore.stop(); + resetApiRequests(); + } + }, + { + fireImmediately: true, + }, + ); +} + +export const WORKSPACES_ROUTES = { + ROOT: '/settings/workspaces', + EDIT: '/settings/workspaces/:action/:id', +}; diff --git a/src/features/workspaces/models/Workspace.js b/src/features/workspaces/models/Workspace.js new file mode 100644 index 000000000..6c73d7095 --- /dev/null +++ b/src/features/workspaces/models/Workspace.js @@ -0,0 +1,25 @@ +import { observable } from 'mobx'; + +export default class Workspace { + id = null; + + @observable name = null; + + @observable order = null; + + @observable services = []; + + @observable userId = null; + + constructor(data) { + if (!data.id) { + throw Error('Workspace requires Id'); + } + + this.id = data.id; + this.name = data.name; + this.order = data.order; + this.services.replace(data.services); + this.userId = data.userId; + } +} diff --git a/src/features/workspaces/store.js b/src/features/workspaces/store.js new file mode 100644 index 000000000..ea601700e --- /dev/null +++ b/src/features/workspaces/store.js @@ -0,0 +1,276 @@ +import { + computed, + observable, + action, +} from 'mobx'; +import localStorage from 'mobx-localstorage'; +import { matchRoute } from '../../helpers/routing-helpers'; +import { workspaceActions } from './actions'; +import { FeatureStore } from '../utils/FeatureStore'; +import { + createWorkspaceRequest, + deleteWorkspaceRequest, + getUserWorkspacesRequest, + updateWorkspaceRequest, +} from './api'; +import { WORKSPACES_ROUTES } from './index'; + +const debug = require('debug')('Franz:feature:workspaces:store'); + +export default class WorkspacesStore extends FeatureStore { + @observable isFeatureEnabled = false; + + @observable isFeatureActive = false; + + @observable isPremiumFeature = true; + + @observable isPremiumUpgradeRequired = true; + + @observable activeWorkspace = null; + + @observable nextWorkspace = null; + + @observable workspaceBeingEdited = null; + + @observable isSwitchingWorkspace = false; + + @observable isWorkspaceDrawerOpen = false; + + @observable isSettingsRouteActive = null; + + @computed get workspaces() { + if (!this.isFeatureActive) return []; + return getUserWorkspacesRequest.result || []; + } + + @computed get settings() { + return localStorage.getItem('workspaces') || {}; + } + + @computed get userHasWorkspaces() { + return getUserWorkspacesRequest.wasExecuted && this.workspaces.length > 0; + } + + start(stores, actions) { + debug('WorkspacesStore::start'); + this.stores = stores; + this.actions = actions; + + this._listenToActions([ + [workspaceActions.edit, this._edit], + [workspaceActions.create, this._create], + [workspaceActions.delete, this._delete], + [workspaceActions.update, this._update], + [workspaceActions.activate, this._setActiveWorkspace], + [workspaceActions.deactivate, this._deactivateActiveWorkspace], + [workspaceActions.toggleWorkspaceDrawer, this._toggleWorkspaceDrawer], + [workspaceActions.openWorkspaceSettings, this._openWorkspaceSettings], + ]); + + this._startReactions([ + this._setWorkspaceBeingEditedReaction, + this._setActiveServiceOnWorkspaceSwitchReaction, + this._setFeatureEnabledReaction, + this._setIsPremiumFeatureReaction, + this._activateLastUsedWorkspaceReaction, + this._openDrawerWithSettingsReaction, + this._cleanupInvalidServiceReferences, + ]); + + getUserWorkspacesRequest.execute(); + this.isFeatureActive = true; + } + + stop() { + debug('WorkspacesStore::stop'); + this.isFeatureActive = false; + this.activeWorkspace = null; + this.nextWorkspace = null; + this.workspaceBeingEdited = null; + this.isSwitchingWorkspace = false; + this.isWorkspaceDrawerOpen = false; + } + + filterServicesByActiveWorkspace = (services) => { + const { activeWorkspace, isFeatureActive } = this; + if (isFeatureActive && activeWorkspace) { + return this.getWorkspaceServices(activeWorkspace); + } + return services; + }; + + getWorkspaceServices(workspace) { + const { services } = this.stores; + return workspace.services.map(id => services.one(id)).filter(s => !!s); + } + + // ========== PRIVATE ========= // + + _wasDrawerOpenBeforeSettingsRoute = null; + + _getWorkspaceById = id => this.workspaces.find(w => w.id === id); + + _updateSettings = (changes) => { + localStorage.setItem('workspaces', { + ...this.settings, + ...changes, + }); + }; + + // Actions + + @action _edit = ({ workspace }) => { + this.stores.router.push(`/settings/workspaces/edit/${workspace.id}`); + }; + + @action _create = async ({ name }) => { + try { + const workspace = await createWorkspaceRequest.execute(name); + await getUserWorkspacesRequest.result.push(workspace); + this._edit({ workspace }); + } catch (error) { + throw error; + } + }; + + @action _delete = async ({ workspace }) => { + try { + await deleteWorkspaceRequest.execute(workspace); + await getUserWorkspacesRequest.result.remove(workspace); + this.stores.router.push('/settings/workspaces'); + } catch (error) { + throw error; + } + }; + + @action _update = async ({ workspace }) => { + try { + await updateWorkspaceRequest.execute(workspace); + // Path local result optimistically + const localWorkspace = this._getWorkspaceById(workspace.id); + Object.assign(localWorkspace, workspace); + this.stores.router.push('/settings/workspaces'); + } catch (error) { + throw error; + } + }; + + @action _setActiveWorkspace = ({ workspace }) => { + // Indicate that we are switching to another workspace + this.isSwitchingWorkspace = true; + this.nextWorkspace = workspace; + // Delay switching to next workspace so that the services loading does not drag down UI + setTimeout(() => { + this.activeWorkspace = workspace; + this._updateSettings({ lastActiveWorkspace: workspace.id }); + }, 100); + // Indicate that we are done switching to the next workspace + setTimeout(() => { + this.isSwitchingWorkspace = false; + this.nextWorkspace = null; + }, 1000); + }; + + @action _deactivateActiveWorkspace = () => { + // Indicate that we are switching to default workspace + this.isSwitchingWorkspace = true; + this.nextWorkspace = null; + this._updateSettings({ lastActiveWorkspace: null }); + // Delay switching to next workspace so that the services loading does not drag down UI + setTimeout(() => { + this.activeWorkspace = null; + }, 100); + // Indicate that we are done switching to the default workspace + setTimeout(() => { this.isSwitchingWorkspace = false; }, 1000); + }; + + @action _toggleWorkspaceDrawer = () => { + this.isWorkspaceDrawerOpen = !this.isWorkspaceDrawerOpen; + }; + + @action _openWorkspaceSettings = () => { + this.actions.ui.openSettings({ path: 'workspaces' }); + }; + + // Reactions + + _setFeatureEnabledReaction = () => { + const { isWorkspaceEnabled } = this.stores.features.features; + this.isFeatureEnabled = isWorkspaceEnabled; + }; + + _setIsPremiumFeatureReaction = () => { + const { features, user } = this.stores; + const { isPremium } = user.data; + const { isWorkspacePremiumFeature } = features.features; + this.isPremiumFeature = isWorkspacePremiumFeature; + this.isPremiumUpgradeRequired = isWorkspacePremiumFeature && !isPremium; + }; + + _setWorkspaceBeingEditedReaction = () => { + const { pathname } = this.stores.router.location; + const match = matchRoute('/settings/workspaces/edit/:id', pathname); + if (match) { + this.workspaceBeingEdited = this._getWorkspaceById(match.id); + } + }; + + _setActiveServiceOnWorkspaceSwitchReaction = () => { + if (!this.isFeatureActive) return; + if (this.activeWorkspace) { + const services = this.stores.services.allDisplayed; + const activeService = services.find(s => s.isActive); + const workspaceServices = this.getWorkspaceServices(this.activeWorkspace); + if (workspaceServices.length <= 0) return; + const isActiveServiceInWorkspace = workspaceServices.includes(activeService); + if (!isActiveServiceInWorkspace) { + this.actions.service.setActive({ serviceId: workspaceServices[0].id }); + } + } + }; + + _activateLastUsedWorkspaceReaction = () => { + if (!this.activeWorkspace && this.userHasWorkspaces) { + const { lastActiveWorkspace } = this.settings; + if (lastActiveWorkspace) { + const workspace = this._getWorkspaceById(lastActiveWorkspace); + if (workspace) this._setActiveWorkspace({ workspace }); + } + } + }; + + _openDrawerWithSettingsReaction = () => { + const { router } = this.stores; + const isWorkspaceSettingsRoute = router.location.pathname.includes(WORKSPACES_ROUTES.ROOT); + const isSwitchingToSettingsRoute = !this.isSettingsRouteActive && isWorkspaceSettingsRoute; + const isLeavingSettingsRoute = !isWorkspaceSettingsRoute && this.isSettingsRouteActive; + + if (isSwitchingToSettingsRoute) { + this.isSettingsRouteActive = true; + this._wasDrawerOpenBeforeSettingsRoute = this.isWorkspaceDrawerOpen; + if (!this._wasDrawerOpenBeforeSettingsRoute) { + workspaceActions.toggleWorkspaceDrawer(); + } + } else if (isLeavingSettingsRoute) { + this.isSettingsRouteActive = false; + if (!this._wasDrawerOpenBeforeSettingsRoute && this.isWorkspaceDrawerOpen) { + workspaceActions.toggleWorkspaceDrawer(); + } + } + }; + + _cleanupInvalidServiceReferences = () => { + const { services } = this.stores; + let invalidServiceReferencesExist = false; + this.workspaces.forEach((workspace) => { + workspace.services.forEach((serviceId) => { + if (!services.one(serviceId)) { + invalidServiceReferencesExist = true; + } + }); + }); + if (invalidServiceReferencesExist) { + getUserWorkspacesRequest.execute(); + } + }; +} -- cgit v1.2.3-70-g09d2