aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/actions/index.js6
-rw-r--r--src/actions/lib/actions.js29
-rw-r--r--src/api/utils/auth.js28
-rw-r--r--src/app.js4
-rw-r--r--src/components/layout/Sidebar.js2
-rw-r--r--src/components/services/tabs/Tabbar.js4
-rw-r--r--src/components/settings/navigation/SettingsNavigation.js12
-rw-r--r--src/config.js2
-rw-r--r--src/environment.js1
-rw-r--r--src/features/delayApp/Component.js2
-rw-r--r--src/features/workspaces/actions.js22
-rw-r--r--src/features/workspaces/api.js39
-rw-r--r--src/features/workspaces/components/CreateWorkspaceForm.js93
-rw-r--r--src/features/workspaces/components/EditWorkspaceForm.js192
-rw-r--r--src/features/workspaces/components/ServiceListItem.js48
-rw-r--r--src/features/workspaces/components/WorkspaceItem.js42
-rw-r--r--src/features/workspaces/components/WorkspacesDashboard.js85
-rw-r--r--src/features/workspaces/containers/EditWorkspaceScreen.js59
-rw-r--r--src/features/workspaces/containers/WorkspacesScreen.js33
-rw-r--r--src/features/workspaces/index.js68
-rw-r--r--src/features/workspaces/models/Workspace.js25
-rw-r--r--src/features/workspaces/state.js15
-rw-r--r--src/features/workspaces/store.js114
-rw-r--r--src/features/workspaces/styles/workspaces-table.scss53
-rw-r--r--src/i18n/locales/de.json12
-rw-r--r--src/i18n/locales/en-US.json14
-rw-r--r--src/lib/Menu.js71
-rw-r--r--src/stores/FeaturesStore.js4
-rw-r--r--src/stores/ServicesStore.js12
-rw-r--r--src/styles/main.scss3
30 files changed, 1069 insertions, 25 deletions
diff --git a/src/actions/index.js b/src/actions/index.js
index 59acabb0b..00f843cd6 100644
--- a/src/actions/index.js
+++ b/src/actions/index.js
@@ -11,6 +11,7 @@ import payment from './payment';
11import news from './news'; 11import news from './news';
12import settings from './settings'; 12import settings from './settings';
13import requests from './requests'; 13import requests from './requests';
14import workspaces from '../features/workspaces/actions';
14 15
15const actions = Object.assign({}, { 16const actions = Object.assign({}, {
16 service, 17 service,
@@ -25,4 +26,7 @@ const actions = Object.assign({}, {
25 requests, 26 requests,
26}); 27});
27 28
28export default defineActions(actions, PropTypes.checkPropTypes); 29export default Object.assign(
30 defineActions(actions, PropTypes.checkPropTypes),
31 { workspaces },
32);
diff --git a/src/actions/lib/actions.js b/src/actions/lib/actions.js
index 499018d70..6571e9441 100644
--- a/src/actions/lib/actions.js
+++ b/src/actions/lib/actions.js
@@ -1,18 +1,23 @@
1export const createActionsFromDefinitions = (actionDefinitions, validate) => {
2 const actions = {};
3 Object.keys(actionDefinitions).forEach((actionName) => {
4 const action = (params) => {
5 const schema = actionDefinitions[actionName];
6 validate(schema, params, actionName);
7 action.notify(params);
8 };
9 actions[actionName] = action;
10 action.listeners = [];
11 action.listen = listener => action.listeners.push(listener);
12 action.notify = params => action.listeners.forEach(listener => listener(params));
13 });
14 return actions;
15};
16
1export default (definitions, validate) => { 17export default (definitions, validate) => {
2 const newActions = {}; 18 const newActions = {};
3 Object.keys(definitions).forEach((scopeName) => { 19 Object.keys(definitions).forEach((scopeName) => {
4 newActions[scopeName] = {}; 20 newActions[scopeName] = createActionsFromDefinitions(definitions[scopeName], validate);
5 Object.keys(definitions[scopeName]).forEach((actionName) => {
6 const action = (params) => {
7 const schema = definitions[scopeName][actionName];
8 validate(schema, params, actionName);
9 action.notify(params);
10 };
11 newActions[scopeName][actionName] = action;
12 action.listeners = [];
13 action.listen = listener => action.listeners.push(listener);
14 action.notify = params => action.listeners.forEach(listener => listener(params));
15 });
16 }); 21 });
17 return newActions; 22 return newActions;
18}; 23};
diff --git a/src/api/utils/auth.js b/src/api/utils/auth.js
new file mode 100644
index 000000000..d469853a5
--- /dev/null
+++ b/src/api/utils/auth.js
@@ -0,0 +1,28 @@
1import { remote } from 'electron';
2import localStorage from 'mobx-localstorage';
3
4const { app } = remote;
5
6export const prepareAuthRequest = (options, auth = true) => {
7 const request = Object.assign(options, {
8 mode: 'cors',
9 headers: Object.assign({
10 'Content-Type': 'application/json',
11 'X-Franz-Source': 'desktop',
12 'X-Franz-Version': app.getVersion(),
13 'X-Franz-platform': process.platform,
14 'X-Franz-Timezone-Offset': new Date().getTimezoneOffset(),
15 'X-Franz-System-Locale': app.getLocale(),
16 }, options.headers),
17 });
18
19 if (auth) {
20 request.headers.Authorization = `Bearer ${localStorage.getItem('authToken')}`;
21 }
22
23 return request;
24};
25
26export const sendAuthRequest = (url, options) => (
27 window.fetch(url, prepareAuthRequest(options))
28);
diff --git a/src/app.js b/src/app.js
index 6660feb46..d3b540f62 100644
--- a/src/app.js
+++ b/src/app.js
@@ -39,6 +39,8 @@ import PricingScreen from './containers/auth/PricingScreen';
39import InviteScreen from './containers/auth/InviteScreen'; 39import InviteScreen from './containers/auth/InviteScreen';
40import AuthLayoutContainer from './containers/auth/AuthLayoutContainer'; 40import AuthLayoutContainer from './containers/auth/AuthLayoutContainer';
41import SubscriptionPopupScreen from './containers/subscription/SubscriptionPopupScreen'; 41import SubscriptionPopupScreen from './containers/subscription/SubscriptionPopupScreen';
42import WorkspacesScreen from './features/workspaces/containers/WorkspacesScreen';
43import EditWorkspaceScreen from './features/workspaces/containers/EditWorkspaceScreen';
42 44
43// Add Polyfills 45// Add Polyfills
44smoothScroll.polyfill(); 46smoothScroll.polyfill();
@@ -75,6 +77,8 @@ window.addEventListener('load', () => {
75 <Route path="/settings/recipes/:filter" component={RecipesScreen} /> 77 <Route path="/settings/recipes/:filter" component={RecipesScreen} />
76 <Route path="/settings/services" component={ServicesScreen} /> 78 <Route path="/settings/services" component={ServicesScreen} />
77 <Route path="/settings/services/:action/:id" component={EditServiceScreen} /> 79 <Route path="/settings/services/:action/:id" component={EditServiceScreen} />
80 <Route path="/settings/workspaces" component={WorkspacesScreen} />
81 <Route path="/settings/workspaces/:action/:id" component={EditWorkspaceScreen} />
78 <Route path="/settings/user" component={AccountScreen} /> 82 <Route path="/settings/user" component={AccountScreen} />
79 <Route path="/settings/user/edit" component={EditUserScreen} /> 83 <Route path="/settings/user/edit" component={EditUserScreen} />
80 <Route path="/settings/app" component={EditSettingsScreen} /> 84 <Route path="/settings/app" component={EditSettingsScreen} />
diff --git a/src/components/layout/Sidebar.js b/src/components/layout/Sidebar.js
index 609a3b604..fcc5b0001 100644
--- a/src/components/layout/Sidebar.js
+++ b/src/components/layout/Sidebar.js
@@ -31,7 +31,7 @@ export default @observer class Sidebar extends Component {
31 openSettings: PropTypes.func.isRequired, 31 openSettings: PropTypes.func.isRequired,
32 toggleMuteApp: PropTypes.func.isRequired, 32 toggleMuteApp: PropTypes.func.isRequired,
33 isAppMuted: PropTypes.bool.isRequired, 33 isAppMuted: PropTypes.bool.isRequired,
34 } 34 };
35 35
36 static contextTypes = { 36 static contextTypes = {
37 intl: intlShape, 37 intl: intlShape,
diff --git a/src/components/services/tabs/Tabbar.js b/src/components/services/tabs/Tabbar.js
index dd5c2140f..5e8260ad0 100644
--- a/src/components/services/tabs/Tabbar.js
+++ b/src/components/services/tabs/Tabbar.js
@@ -19,7 +19,7 @@ export default @observer class TabBar extends Component {
19 updateService: PropTypes.func.isRequired, 19 updateService: PropTypes.func.isRequired,
20 showMessageBadgeWhenMutedSetting: PropTypes.bool.isRequired, 20 showMessageBadgeWhenMutedSetting: PropTypes.bool.isRequired,
21 showMessageBadgesEvenWhenMuted: PropTypes.bool.isRequired, 21 showMessageBadgesEvenWhenMuted: PropTypes.bool.isRequired,
22 } 22 };
23 23
24 onSortEnd = ({ oldIndex, newIndex }) => { 24 onSortEnd = ({ oldIndex, newIndex }) => {
25 const { 25 const {
@@ -45,7 +45,7 @@ export default @observer class TabBar extends Component {
45 redirect: false, 45 redirect: false,
46 }); 46 });
47 } 47 }
48 } 48 };
49 49
50 disableService({ serviceId }) { 50 disableService({ serviceId }) {
51 this.toggleService({ serviceId, isEnabled: false }); 51 this.toggleService({ serviceId, isEnabled: false });
diff --git a/src/components/settings/navigation/SettingsNavigation.js b/src/components/settings/navigation/SettingsNavigation.js
index 953f702f8..4a80bb126 100644
--- a/src/components/settings/navigation/SettingsNavigation.js
+++ b/src/components/settings/navigation/SettingsNavigation.js
@@ -14,6 +14,10 @@ const messages = defineMessages({
14 id: 'settings.navigation.yourServices', 14 id: 'settings.navigation.yourServices',
15 defaultMessage: '!!!Your services', 15 defaultMessage: '!!!Your services',
16 }, 16 },
17 yourWorkspaces: {
18 id: 'settings.navigation.yourWorkspaces',
19 defaultMessage: '!!!Your workspaces',
20 },
17 account: { 21 account: {
18 id: 'settings.navigation.account', 22 id: 'settings.navigation.account',
19 defaultMessage: '!!!Account', 23 defaultMessage: '!!!Account',
@@ -64,6 +68,14 @@ export default @inject('stores') @observer class SettingsNavigation extends Comp
64 <span className="badge">{serviceCount}</span> 68 <span className="badge">{serviceCount}</span>
65 </Link> 69 </Link>
66 <Link 70 <Link
71 to="/settings/workspaces"
72 className="settings-navigation__link"
73 activeClassName="is-active"
74 >
75 {intl.formatMessage(messages.yourWorkspaces)}
76 {' '}
77 </Link>
78 <Link
67 to="/settings/user" 79 to="/settings/user"
68 className="settings-navigation__link" 80 className="settings-navigation__link"
69 activeClassName="is-active" 81 activeClassName="is-active"
diff --git a/src/config.js b/src/config.js
index a782ad667..30a5a5cc0 100644
--- a/src/config.js
+++ b/src/config.js
@@ -41,6 +41,8 @@ export const DEFAULT_FEATURES_CONFIG = {
41 }, 41 },
42 isServiceProxyEnabled: false, 42 isServiceProxyEnabled: false,
43 isServiceProxyPremiumFeature: true, 43 isServiceProxyPremiumFeature: true,
44 isWorkspacePremiumFeature: true,
45 isWorkspaceEnabled: true,
44}; 46};
45 47
46export const DEFAULT_WINDOW_OPTIONS = { 48export const DEFAULT_WINDOW_OPTIONS = {
diff --git a/src/environment.js b/src/environment.js
index 73b1c7ab2..d67fd6adb 100644
--- a/src/environment.js
+++ b/src/environment.js
@@ -28,3 +28,4 @@ if (!isDevMode || (isDevMode && useLiveAPI)) {
28} 28}
29 29
30export const API = api; 30export const API = api;
31export const API_VERSION = 'v1';
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
38 38
39 state = { 39 state = {
40 countdown: config.delayDuration, 40 countdown: config.delayDuration,
41 } 41 };
42 42
43 countdownInterval = null; 43 countdownInterval = null;
44 44
diff --git a/src/features/workspaces/actions.js b/src/features/workspaces/actions.js
new file mode 100644
index 000000000..25246de09
--- /dev/null
+++ b/src/features/workspaces/actions.js
@@ -0,0 +1,22 @@
1import PropTypes from 'prop-types';
2import Workspace from './models/Workspace';
3import { createActionsFromDefinitions } from '../../actions/lib/actions';
4
5export default createActionsFromDefinitions({
6 edit: {
7 workspace: PropTypes.instanceOf(Workspace).isRequired,
8 },
9 create: {
10 name: PropTypes.string.isRequired,
11 },
12 delete: {
13 workspace: PropTypes.instanceOf(Workspace).isRequired,
14 },
15 update: {
16 workspace: PropTypes.instanceOf(Workspace).isRequired,
17 },
18 activate: {
19 workspace: PropTypes.instanceOf(Workspace).isRequired,
20 },
21 deactivate: {},
22}, PropTypes.checkPropTypes);
diff --git a/src/features/workspaces/api.js b/src/features/workspaces/api.js
new file mode 100644
index 000000000..733cb5593
--- /dev/null
+++ b/src/features/workspaces/api.js
@@ -0,0 +1,39 @@
1import { pick } from 'lodash';
2import { sendAuthRequest } from '../../api/utils/auth';
3import { API, API_VERSION } from '../../environment';
4
5export default {
6 getUserWorkspaces: async () => {
7 const url = `${API}/${API_VERSION}/workspace`;
8 const request = await sendAuthRequest(url, { method: 'GET' });
9 if (!request.ok) throw request;
10 return request.json();
11 },
12
13 createWorkspace: async (name) => {
14 const url = `${API}/${API_VERSION}/workspace`;
15 const request = await sendAuthRequest(url, {
16 method: 'POST',
17 body: JSON.stringify({ name }),
18 });
19 if (!request.ok) throw request;
20 return request.json();
21 },
22
23 deleteWorkspace: async (workspace) => {
24 const url = `${API}/${API_VERSION}/workspace/${workspace.id}`;
25 const request = await sendAuthRequest(url, { method: 'DELETE' });
26 if (!request.ok) throw request;
27 return request.json();
28 },
29
30 updateWorkspace: async (workspace) => {
31 const url = `${API}/${API_VERSION}/workspace/${workspace.id}`;
32 const request = await sendAuthRequest(url, {
33 method: 'PUT',
34 body: JSON.stringify(pick(workspace, ['name', 'services'])),
35 });
36 if (!request.ok) throw request;
37 return request.json();
38 },
39};
diff --git a/src/features/workspaces/components/CreateWorkspaceForm.js b/src/features/workspaces/components/CreateWorkspaceForm.js
new file mode 100644
index 000000000..83f6e07f7
--- /dev/null
+++ b/src/features/workspaces/components/CreateWorkspaceForm.js
@@ -0,0 +1,93 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import { Input, Button } from '@meetfranz/forms';
6import injectSheet from 'react-jss';
7import Form from '../../../lib/Form';
8import { required } from '../../../helpers/validation-helpers';
9
10const messages = defineMessages({
11 submitButton: {
12 id: 'settings.workspace.add.form.submitButton',
13 defaultMessage: '!!!Save workspace',
14 },
15 name: {
16 id: 'settings.workspace.add.form.name',
17 defaultMessage: '!!!Name',
18 },
19});
20
21const styles = () => ({
22 form: {
23 display: 'flex',
24 },
25 input: {
26 flexGrow: 1,
27 marginRight: '10px',
28 },
29 submitButton: {
30 height: 'inherit',
31 marginTop: '3px',
32 },
33});
34
35@injectSheet(styles) @observer
36class CreateWorkspaceForm extends Component {
37 static contextTypes = {
38 intl: intlShape,
39 };
40
41 static propTypes = {
42 classes: PropTypes.object.isRequired,
43 onSubmit: PropTypes.func.isRequired,
44 };
45
46 form = (() => {
47 const { intl } = this.context;
48 return new Form({
49 fields: {
50 name: {
51 label: intl.formatMessage(messages.name),
52 placeholder: intl.formatMessage(messages.name),
53 value: '',
54 validators: [required],
55 },
56 },
57 });
58 })();
59
60 submitForm() {
61 const { form } = this;
62 form.submit({
63 onSuccess: async (f) => {
64 const { onSubmit } = this.props;
65 onSubmit(f.values());
66 },
67 });
68 }
69
70 render() {
71 const { intl } = this.context;
72 const { classes } = this.props;
73 const { form } = this;
74 return (
75 <div className={classes.form}>
76 <Input
77 className={classes.input}
78 {...form.$('name').bind()}
79 showLabel={false}
80 onEnterKey={this.submitForm.bind(this, form)}
81 />
82 <Button
83 className={classes.submitButton}
84 type="submit"
85 label={intl.formatMessage(messages.submitButton)}
86 onClick={this.submitForm.bind(this, form)}
87 />
88 </div>
89 );
90 }
91}
92
93export default CreateWorkspaceForm;
diff --git a/src/features/workspaces/components/EditWorkspaceForm.js b/src/features/workspaces/components/EditWorkspaceForm.js
new file mode 100644
index 000000000..48090f608
--- /dev/null
+++ b/src/features/workspaces/components/EditWorkspaceForm.js
@@ -0,0 +1,192 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import { Link } from 'react-router';
6import { Input, Button } from '@meetfranz/forms';
7import injectSheet from 'react-jss';
8
9import Workspace from '../models/Workspace';
10import Service from '../../../models/Service';
11import Form from '../../../lib/Form';
12import { required } from '../../../helpers/validation-helpers';
13import ServiceListItem from './ServiceListItem';
14
15const messages = defineMessages({
16 buttonDelete: {
17 id: 'settings.workspace.form.buttonDelete',
18 defaultMessage: '!!!Delete workspace',
19 },
20 buttonSave: {
21 id: 'settings.workspace.form.buttonSave',
22 defaultMessage: '!!!Save workspace',
23 },
24 name: {
25 id: 'settings.workspace.form.name',
26 defaultMessage: '!!!Name',
27 },
28 yourWorkspaces: {
29 id: 'settings.workspace.form.yourWorkspaces',
30 defaultMessage: '!!!Your workspaces',
31 },
32 servicesInWorkspaceHeadline: {
33 id: 'settings.workspace.form.servicesInWorkspaceHeadline',
34 defaultMessage: '!!!Services in this Workspace',
35 },
36});
37
38const styles = () => ({
39 nameInput: {
40 height: 'auto',
41 },
42 serviceList: {
43 height: 'auto',
44 },
45});
46
47@injectSheet(styles) @observer
48class EditWorkspaceForm extends Component {
49 static contextTypes = {
50 intl: intlShape,
51 };
52
53 static propTypes = {
54 classes: PropTypes.object.isRequired,
55 isDeleting: PropTypes.bool.isRequired,
56 isSaving: PropTypes.bool.isRequired,
57 onDelete: PropTypes.func.isRequired,
58 onSave: PropTypes.func.isRequired,
59 services: PropTypes.arrayOf(PropTypes.instanceOf(Service)).isRequired,
60 workspace: PropTypes.instanceOf(Workspace).isRequired,
61 };
62
63 form = this.prepareWorkspaceForm(this.props.workspace);
64
65 componentWillReceiveProps(nextProps) {
66 const { workspace } = this.props;
67 if (workspace.id !== nextProps.workspace.id) {
68 this.form = this.prepareWorkspaceForm(nextProps.workspace);
69 }
70 }
71
72 prepareWorkspaceForm(workspace) {
73 const { intl } = this.context;
74 return new Form({
75 fields: {
76 name: {
77 label: intl.formatMessage(messages.name),
78 placeholder: intl.formatMessage(messages.name),
79 value: workspace.name,
80 validators: [required],
81 },
82 services: {
83 value: workspace.services.slice(),
84 },
85 },
86 });
87 }
88
89 submitForm(form) {
90 form.submit({
91 onSuccess: async (f) => {
92 const { onSave } = this.props;
93 const values = f.values();
94 onSave(values);
95 },
96 onError: async () => {},
97 });
98 }
99
100 toggleService(service) {
101 const servicesField = this.form.$('services');
102 const serviceIds = servicesField.value;
103 if (serviceIds.includes(service.id)) {
104 serviceIds.splice(serviceIds.indexOf(service.id), 1);
105 } else {
106 serviceIds.push(service.id);
107 }
108 servicesField.set(serviceIds);
109 }
110
111 render() {
112 const { intl } = this.context;
113 const {
114 classes,
115 isDeleting,
116 isSaving,
117 onDelete,
118 workspace,
119 services,
120 } = this.props;
121 const { form } = this;
122 const workspaceServices = form.$('services').value;
123 return (
124 <div className="settings__main">
125 <div className="settings__header">
126 <span className="settings__header-item">
127 <Link to="/settings/workspaces">
128 {intl.formatMessage(messages.yourWorkspaces)}
129 </Link>
130 </span>
131 <span className="separator" />
132 <span className="settings__header-item">
133 {workspace.name}
134 </span>
135 </div>
136 <div className="settings__body">
137 <div className={classes.nameInput}>
138 <Input {...form.$('name').bind()} />
139 </div>
140 <h2>{intl.formatMessage(messages.servicesInWorkspaceHeadline)}</h2>
141 <div className={classes.serviceList}>
142 {services.map(s => (
143 <ServiceListItem
144 key={s.id}
145 service={s}
146 isInWorkspace={workspaceServices.includes(s.id)}
147 onToggle={() => this.toggleService(s)}
148 />
149 ))}
150 </div>
151 </div>
152 <div className="settings__controls">
153 {/* ===== Delete Button ===== */}
154 {isDeleting ? (
155 <Button
156 label={intl.formatMessage(messages.buttonDelete)}
157 loaded={false}
158 buttonType="secondary"
159 className="settings__delete-button"
160 disabled
161 />
162 ) : (
163 <Button
164 buttonType="danger"
165 label={intl.formatMessage(messages.buttonDelete)}
166 className="settings__delete-button"
167 onClick={onDelete}
168 />
169 )}
170 {/* ===== Save Button ===== */}
171 {isSaving ? (
172 <Button
173 type="submit"
174 label={intl.formatMessage(messages.buttonSave)}
175 loaded={!isSaving}
176 buttonType="secondary"
177 disabled
178 />
179 ) : (
180 <Button
181 type="submit"
182 label={intl.formatMessage(messages.buttonSave)}
183 onClick={this.submitForm.bind(this, form)}
184 />
185 )}
186 </div>
187 </div>
188 );
189 }
190}
191
192export default EditWorkspaceForm;
diff --git a/src/features/workspaces/components/ServiceListItem.js b/src/features/workspaces/components/ServiceListItem.js
new file mode 100644
index 000000000..146cc5a36
--- /dev/null
+++ b/src/features/workspaces/components/ServiceListItem.js
@@ -0,0 +1,48 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import injectSheet from 'react-jss';
5import { Toggle } from '@meetfranz/forms';
6
7import Service from '../../../models/Service';
8
9const styles = () => ({
10 service: {
11 height: 'auto',
12 display: 'flex',
13 },
14 name: {
15 marginTop: '4px',
16 },
17});
18
19@injectSheet(styles) @observer
20class ServiceListItem extends Component {
21 static propTypes = {
22 classes: PropTypes.object.isRequired,
23 isInWorkspace: PropTypes.bool.isRequired,
24 onToggle: PropTypes.func.isRequired,
25 service: PropTypes.instanceOf(Service).isRequired,
26 };
27
28 render() {
29 const {
30 classes,
31 isInWorkspace,
32 onToggle,
33 service,
34 } = this.props;
35
36 return (
37 <div className={classes.service}>
38 <Toggle
39 checked={isInWorkspace}
40 onChange={onToggle}
41 label={service.name}
42 />
43 </div>
44 );
45 }
46}
47
48export default ServiceListItem;
diff --git a/src/features/workspaces/components/WorkspaceItem.js b/src/features/workspaces/components/WorkspaceItem.js
new file mode 100644
index 000000000..b2c2a4830
--- /dev/null
+++ b/src/features/workspaces/components/WorkspaceItem.js
@@ -0,0 +1,42 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { intlShape } from 'react-intl';
4import { observer } from 'mobx-react';
5import classnames from 'classnames';
6import Workspace from '../models/Workspace';
7
8// const messages = defineMessages({});
9
10@observer
11class WorkspaceItem extends Component {
12 static propTypes = {
13 workspace: PropTypes.instanceOf(Workspace).isRequired,
14 onItemClick: PropTypes.func.isRequired,
15 };
16
17 static contextTypes = {
18 intl: intlShape,
19 };
20
21 render() {
22 const { workspace, onItemClick } = this.props;
23 // const { intl } = this.context;
24
25 return (
26 <tr
27 className={classnames({
28 'workspace-table__row': true,
29 })}
30 >
31 <td
32 className="workspace-table__column-name"
33 onClick={() => onItemClick(workspace)}
34 >
35 {workspace.name}
36 </td>
37 </tr>
38 );
39 }
40}
41
42export default WorkspaceItem;
diff --git a/src/features/workspaces/components/WorkspacesDashboard.js b/src/features/workspaces/components/WorkspacesDashboard.js
new file mode 100644
index 000000000..917807302
--- /dev/null
+++ b/src/features/workspaces/components/WorkspacesDashboard.js
@@ -0,0 +1,85 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import injectSheet from 'react-jss';
6
7import Loader from '../../../components/ui/Loader';
8import WorkspaceItem from './WorkspaceItem';
9import CreateWorkspaceForm from './CreateWorkspaceForm';
10
11const messages = defineMessages({
12 headline: {
13 id: 'settings.workspaces.headline',
14 defaultMessage: '!!!Your workspaces',
15 },
16 noServicesAdded: {
17 id: 'settings.workspaces.noWorkspacesAdded',
18 defaultMessage: '!!!You haven\'t added any workspaces yet.',
19 },
20});
21
22const styles = () => ({
23 createForm: {
24 height: 'auto',
25 marginBottom: '20px',
26 },
27});
28
29@observer @injectSheet(styles)
30class WorkspacesDashboard extends Component {
31 static propTypes = {
32 classes: PropTypes.object.isRequired,
33 isLoading: PropTypes.bool.isRequired,
34 onCreateWorkspaceSubmit: PropTypes.func.isRequired,
35 onWorkspaceClick: PropTypes.func.isRequired,
36 workspaces: MobxPropTypes.arrayOrObservableArray.isRequired,
37 };
38
39 static contextTypes = {
40 intl: intlShape,
41 };
42
43 render() {
44 const {
45 workspaces,
46 isLoading,
47 onCreateWorkspaceSubmit,
48 onWorkspaceClick,
49 classes,
50 } = this.props;
51 const { intl } = this.context;
52
53 return (
54 <div className="settings__main">
55 <div className="settings__header">
56 <h1>{intl.formatMessage(messages.headline)}</h1>
57 </div>
58 <div className="settings__body">
59 <div className={classes.body}>
60 <div className={classes.createForm}>
61 <CreateWorkspaceForm onSubmit={onCreateWorkspaceSubmit} />
62 </div>
63 {isLoading ? (
64 <Loader />
65 ) : (
66 <table className="workspace-table">
67 <tbody>
68 {workspaces.map(workspace => (
69 <WorkspaceItem
70 key={workspace.id}
71 workspace={workspace}
72 onItemClick={w => onWorkspaceClick(w)}
73 />
74 ))}
75 </tbody>
76 </table>
77 )}
78 </div>
79 </div>
80 </div>
81 );
82 }
83}
84
85export default WorkspacesDashboard;
diff --git a/src/features/workspaces/containers/EditWorkspaceScreen.js b/src/features/workspaces/containers/EditWorkspaceScreen.js
new file mode 100644
index 000000000..1b13bc2d4
--- /dev/null
+++ b/src/features/workspaces/containers/EditWorkspaceScreen.js
@@ -0,0 +1,59 @@
1import React, { Component } from 'react';
2import { inject, observer } from 'mobx-react';
3import PropTypes from 'prop-types';
4
5import ErrorBoundary from '../../../components/util/ErrorBoundary';
6import EditWorkspaceForm from '../components/EditWorkspaceForm';
7import { workspacesState } from '../state';
8import ServicesStore from '../../../stores/ServicesStore';
9import Workspace from '../models/Workspace';
10
11@inject('stores', 'actions') @observer
12class EditWorkspaceScreen extends Component {
13 static propTypes = {
14 actions: PropTypes.shape({
15 workspace: PropTypes.shape({
16 delete: PropTypes.func.isRequired,
17 }),
18 }).isRequired,
19 stores: PropTypes.shape({
20 services: PropTypes.instanceOf(ServicesStore).isRequired,
21 }).isRequired,
22 };
23
24 onDelete = () => {
25 const { workspaceBeingEdited } = workspacesState;
26 const { actions } = this.props;
27 if (!workspaceBeingEdited) return null;
28 actions.workspaces.delete({ workspace: workspaceBeingEdited });
29 };
30
31 onSave = (values) => {
32 const { workspaceBeingEdited } = workspacesState;
33 const { actions } = this.props;
34 const workspace = new Workspace(
35 Object.assign({}, workspaceBeingEdited, values),
36 );
37 actions.workspaces.update({ workspace });
38 };
39
40 render() {
41 const { workspaceBeingEdited } = workspacesState;
42 const { stores } = this.props;
43 if (!workspaceBeingEdited) return null;
44 return (
45 <ErrorBoundary>
46 <EditWorkspaceForm
47 workspace={workspaceBeingEdited}
48 services={stores.services.all}
49 onDelete={this.onDelete}
50 onSave={this.onSave}
51 isDeleting={false}
52 isSaving={false}
53 />
54 </ErrorBoundary>
55 );
56 }
57}
58
59export default EditWorkspaceScreen;
diff --git a/src/features/workspaces/containers/WorkspacesScreen.js b/src/features/workspaces/containers/WorkspacesScreen.js
new file mode 100644
index 000000000..94e714255
--- /dev/null
+++ b/src/features/workspaces/containers/WorkspacesScreen.js
@@ -0,0 +1,33 @@
1import React, { Component } from 'react';
2import { inject, observer } from 'mobx-react';
3import PropTypes from 'prop-types';
4import { workspacesState } from '../state';
5import WorkspacesDashboard from '../components/WorkspacesDashboard';
6import ErrorBoundary from '../../../components/util/ErrorBoundary';
7
8@inject('actions') @observer
9class WorkspacesScreen extends Component {
10 static propTypes = {
11 actions: PropTypes.shape({
12 workspace: PropTypes.shape({
13 edit: PropTypes.func.isRequired,
14 }),
15 }).isRequired,
16 };
17
18 render() {
19 const { actions } = this.props;
20 return (
21 <ErrorBoundary>
22 <WorkspacesDashboard
23 workspaces={workspacesState.workspaces}
24 isLoading={workspacesState.isLoading}
25 onCreateWorkspaceSubmit={data => actions.workspaces.create(data)}
26 onWorkspaceClick={w => actions.workspaces.edit({ workspace: w })}
27 />
28 </ErrorBoundary>
29 );
30 }
31}
32
33export default WorkspacesScreen;
diff --git a/src/features/workspaces/index.js b/src/features/workspaces/index.js
new file mode 100644
index 000000000..8091f49fc
--- /dev/null
+++ b/src/features/workspaces/index.js
@@ -0,0 +1,68 @@
1import { reaction, runInAction } from 'mobx';
2import WorkspacesStore from './store';
3import api from './api';
4import { workspacesState, resetState } from './state';
5
6const debug = require('debug')('Franz:feature:workspaces');
7
8let store = null;
9
10export const filterServicesByActiveWorkspace = (services) => {
11 const { isFeatureActive, activeWorkspace } = workspacesState;
12 if (isFeatureActive && activeWorkspace) {
13 return services.filter(s => activeWorkspace.services.includes(s.id));
14 }
15 return services;
16};
17
18export const getActiveWorkspaceServices = (services) => {
19 return filterServicesByActiveWorkspace(services);
20};
21
22export default function initWorkspaces(stores, actions) {
23 const { features, user } = stores;
24
25 // Toggle workspace feature
26 reaction(
27 () => (
28 features.features.isWorkspaceEnabled && (
29 !features.features.isWorkspacePremiumFeature || user.data.isPremium
30 )
31 ),
32 (isEnabled) => {
33 if (isEnabled) {
34 debug('Initializing `workspaces` feature');
35 store = new WorkspacesStore(stores, api, actions, workspacesState);
36 store.initialize();
37 runInAction(() => { workspacesState.isFeatureActive = true; });
38 } else if (store) {
39 debug('Disabling `workspaces` feature');
40 runInAction(() => { workspacesState.isFeatureActive = false; });
41 store.teardown();
42 store = null;
43 resetState(); // Reset state to default
44 }
45 },
46 {
47 fireImmediately: true,
48 },
49 );
50
51 // Update active service on workspace switches
52 reaction(() => ({
53 isFeatureActive: workspacesState.isFeatureActive,
54 activeWorkspace: workspacesState.activeWorkspace,
55 }), ({ isFeatureActive, activeWorkspace }) => {
56 if (!isFeatureActive) return;
57 if (activeWorkspace) {
58 const services = stores.services.allDisplayed;
59 const activeService = services.find(s => s.isActive);
60 const workspaceServices = filterServicesByActiveWorkspace(services);
61 const isActiveServiceInWorkspace = workspaceServices.includes(activeService);
62 if (!isActiveServiceInWorkspace) {
63 console.log(workspaceServices[0].id);
64 actions.service.setActive({ serviceId: workspaceServices[0].id });
65 }
66 }
67 });
68}
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 @@
1import { observable } from 'mobx';
2
3export default class Workspace {
4 id = null;
5
6 @observable name = null;
7
8 @observable order = null;
9
10 @observable services = [];
11
12 @observable userId = null;
13
14 constructor(data) {
15 if (!data.id) {
16 throw Error('Workspace requires Id');
17 }
18
19 this.id = data.id;
20 this.name = data.name;
21 this.order = data.order;
22 this.services.replace(data.services);
23 this.userId = data.userId;
24 }
25}
diff --git a/src/features/workspaces/state.js b/src/features/workspaces/state.js
new file mode 100644
index 000000000..963b96f81
--- /dev/null
+++ b/src/features/workspaces/state.js
@@ -0,0 +1,15 @@
1import { observable } from 'mobx';
2
3const defaultState = {
4 activeWorkspace: null,
5 isLoading: false,
6 isFeatureActive: false,
7 workspaces: [],
8 workspaceBeingEdited: null,
9};
10
11export const workspacesState = observable(defaultState);
12
13export function resetState() {
14 Object.assign(workspacesState, defaultState);
15}
diff --git a/src/features/workspaces/store.js b/src/features/workspaces/store.js
new file mode 100644
index 000000000..a2997a0d2
--- /dev/null
+++ b/src/features/workspaces/store.js
@@ -0,0 +1,114 @@
1import { observable, reaction, action } from 'mobx';
2import Store from '../../stores/lib/Store';
3import CachedRequest from '../../stores/lib/CachedRequest';
4import Workspace from './models/Workspace';
5import { matchRoute } from '../../helpers/routing-helpers';
6import workspaceActions from './actions';
7
8const debug = require('debug')('Franz:feature:workspaces');
9
10export default class WorkspacesStore extends Store {
11 @observable allWorkspacesRequest = new CachedRequest(this.api, 'getUserWorkspaces');
12
13 constructor(stores, api, actions, state) {
14 super(stores, api, actions);
15 this.state = state;
16 }
17
18 setup() {
19 debug('fetching workspaces');
20 this.allWorkspacesRequest.execute();
21
22 /**
23 * Update the state workspaces array when workspaces request has results.
24 */
25 reaction(
26 () => this.allWorkspacesRequest.result,
27 workspaces => this._setWorkspaces(workspaces),
28 );
29 /**
30 * Update the loading state when workspace request is executing.
31 */
32 reaction(
33 () => this.allWorkspacesRequest.isExecuting,
34 isExecuting => this._setIsLoading(isExecuting),
35 );
36 /**
37 * Update the state with the workspace to be edited when route matches.
38 */
39 reaction(
40 () => ({
41 pathname: this.stores.router.location.pathname,
42 workspaces: this.state.workspaces,
43 }),
44 ({ pathname }) => {
45 const match = matchRoute('/settings/workspaces/edit/:id', pathname);
46 if (match) {
47 this.state.workspaceBeingEdited = this._getWorkspaceById(match.id);
48 }
49 },
50 );
51
52 workspaceActions.edit.listen(this._edit);
53 workspaceActions.create.listen(this._create);
54 workspaceActions.delete.listen(this._delete);
55 workspaceActions.update.listen(this._update);
56 workspaceActions.activate.listen(this._setActiveWorkspace);
57 workspaceActions.deactivate.listen(this._deactivateActiveWorkspace);
58 }
59
60 _getWorkspaceById = id => this.state.workspaces.find(w => w.id === id);
61
62 @action _setWorkspaces = (workspaces) => {
63 debug('setting user workspaces', workspaces.slice());
64 this.state.workspaces = workspaces.map(data => new Workspace(data));
65 };
66
67 @action _setIsLoading = (isLoading) => {
68 this.state.isLoading = isLoading;
69 };
70
71 @action _edit = ({ workspace }) => {
72 this.stores.router.push(`/settings/workspaces/edit/${workspace.id}`);
73 };
74
75 @action _create = async ({ name }) => {
76 try {
77 const result = await this.api.createWorkspace(name);
78 const workspace = new Workspace(result);
79 this.state.workspaces.push(workspace);
80 this._edit({ workspace });
81 } catch (error) {
82 throw error;
83 }
84 };
85
86 @action _delete = async ({ workspace }) => {
87 try {
88 await this.api.deleteWorkspace(workspace);
89 this.state.workspaces.remove(workspace);
90 this.stores.router.push('/settings/workspaces');
91 } catch (error) {
92 throw error;
93 }
94 };
95
96 @action _update = async ({ workspace }) => {
97 try {
98 await this.api.updateWorkspace(workspace);
99 const localWorkspace = this.state.workspaces.find(ws => ws.id === workspace.id);
100 Object.assign(localWorkspace, workspace);
101 this.stores.router.push('/settings/workspaces');
102 } catch (error) {
103 throw error;
104 }
105 };
106
107 @action _setActiveWorkspace = ({ workspace }) => {
108 this.state.activeWorkspace = workspace;
109 };
110
111 @action _deactivateActiveWorkspace = () => {
112 this.state.activeWorkspace = null;
113 };
114}
diff --git a/src/features/workspaces/styles/workspaces-table.scss b/src/features/workspaces/styles/workspaces-table.scss
new file mode 100644
index 000000000..6d0e7b4f5
--- /dev/null
+++ b/src/features/workspaces/styles/workspaces-table.scss
@@ -0,0 +1,53 @@
1@import '../../../styles/config';
2
3.theme__dark .workspace-table {
4 .workspace-table__column-info .mdi { color: $dark-theme-gray-lightest; }
5
6 .workspace-table__row {
7 border-bottom: 1px solid $dark-theme-gray-darker;
8
9 &:hover { background: $dark-theme-gray-darker; }
10 &.workspace-table__row--disabled { color: $dark-theme-gray; }
11 }
12}
13
14.workspace-table {
15 width: 100%;
16
17 .workspace-table__toggle {
18 width: 60px;
19
20 .franz-form__field {
21 margin-bottom: 0;
22 }
23 }
24
25 .workspace-table__column-action {
26 width: 40px
27 }
28
29 .workspace-table__column-info {
30 width: 40px;
31
32 .mdi {
33 color: $theme-gray-light;
34 display: block;
35 font-size: 18px;
36 }
37 }
38
39 .workspace-table__row {
40 border-bottom: 1px solid $theme-gray-lightest;
41
42 &:hover {
43 cursor: initial;
44 background: $theme-gray-lightest;
45 }
46
47 &.workspace-table__row--disabled {
48 color: $theme-gray-light;
49 }
50 }
51
52 td { padding: 10px; }
53}
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index b5abb56d4..0c1fb8aa6 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -74,6 +74,9 @@
74 "menu.window" : "Fenster", 74 "menu.window" : "Fenster",
75 "menu.window.close" : "Schließen", 75 "menu.window.close" : "Schließen",
76 "menu.window.minimize" : "Minimieren", 76 "menu.window.minimize" : "Minimieren",
77 "menu.workspaces": "Workspaces",
78 "menu.workspaces.defaultWorkspace": "All services",
79 "menu.workspaces.addNewWorkspace": "Add New Workspace",
77 "password.email.label" : "E-Mail Adresse", 80 "password.email.label" : "E-Mail Adresse",
78 "password.headline" : "Passwort zurücksetzen", 81 "password.headline" : "Passwort zurücksetzen",
79 "password.link.login" : "An Deinem Konto anmelden", 82 "password.link.login" : "An Deinem Konto anmelden",
@@ -158,6 +161,7 @@
158 "settings.navigation.logout" : "Abmelden", 161 "settings.navigation.logout" : "Abmelden",
159 "settings.navigation.settings" : "Einstellungen", 162 "settings.navigation.settings" : "Einstellungen",
160 "settings.navigation.yourServices" : "Deine Dienste", 163 "settings.navigation.yourServices" : "Deine Dienste",
164 "settings.navigation.yourWorkspaces": "Deine Workspaces",
161 "settings.recipes.all" : "Alle Dienste", 165 "settings.recipes.all" : "Alle Dienste",
162 "settings.recipes.dev" : "Entwicklung", 166 "settings.recipes.dev" : "Entwicklung",
163 "settings.recipes.headline" : "Verfügbare Dienste", 167 "settings.recipes.headline" : "Verfügbare Dienste",
@@ -216,6 +220,14 @@
216 "settings.services.tooltip.isMuted" : "Alle Töne sind deaktiviert", 220 "settings.services.tooltip.isMuted" : "Alle Töne sind deaktiviert",
217 "settings.services.tooltip.notificationsDisabled" : "Benachrichtigungen deaktiviert", 221 "settings.services.tooltip.notificationsDisabled" : "Benachrichtigungen deaktiviert",
218 "settings.services.updatedInfo" : "Deine Änderungen wurden gespeichert", 222 "settings.services.updatedInfo" : "Deine Änderungen wurden gespeichert",
223 "settings.workspaces.headline": "Deine Workspaces",
224 "settings.workspace.add.form.submitButton": "Workspace erstellen",
225 "settings.workspace.add.form.name": "Name",
226 "settings.workspace.form.yourWorkspaces": "Deine Workspaces",
227 "settings.workspace.form.name": "Name",
228 "settings.workspace.form.buttonDelete": "Workspace löschen",
229 "settings.workspace.form.buttonSave": "Workspace speichern",
230 "settings.workspace.form.servicesInWorkspaceHeadline": "Services in diesem Workspace",
219 "settings.user.form.accountType.company" : "Firma", 231 "settings.user.form.accountType.company" : "Firma",
220 "settings.user.form.accountType.individual" : "Einzelperson", 232 "settings.user.form.accountType.individual" : "Einzelperson",
221 "settings.user.form.accountType.label" : "Konto-Typ", 233 "settings.user.form.accountType.label" : "Konto-Typ",
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index 25ec027d8..2a51662a2 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -97,6 +97,7 @@
97 "settings.invite.headline": "Invite Friends", 97 "settings.invite.headline": "Invite Friends",
98 "settings.navigation.availableServices": "Available services", 98 "settings.navigation.availableServices": "Available services",
99 "settings.navigation.yourServices": "Your services", 99 "settings.navigation.yourServices": "Your services",
100 "settings.navigation.yourWorkspaces": "Your workspaces",
100 "settings.navigation.account": "Account", 101 "settings.navigation.account": "Account",
101 "settings.navigation.settings": "Settings", 102 "settings.navigation.settings": "Settings",
102 "settings.navigation.inviteFriends": "Invite Friends", 103 "settings.navigation.inviteFriends": "Invite Friends",
@@ -199,6 +200,14 @@
199 "settings.user.form.accountType.individual": "Individual", 200 "settings.user.form.accountType.individual": "Individual",
200 "settings.user.form.accountType.non-profit": "Non-Profit", 201 "settings.user.form.accountType.non-profit": "Non-Profit",
201 "settings.user.form.accountType.company": "Company", 202 "settings.user.form.accountType.company": "Company",
203 "settings.workspaces.headline": "Your workspaces",
204 "settings.workspace.add.form.submitButton": "Create Workspace",
205 "settings.workspace.add.form.name": "Name",
206 "settings.workspace.form.yourWorkspaces": "Your workspaces",
207 "settings.workspace.form.name": "Name",
208 "settings.workspace.form.buttonDelete": "Delete Workspace",
209 "settings.workspace.form.buttonSave": "Save Workspace",
210 "settings.workspace.form.servicesInWorkspaceHeadline": "Services in this Workspace",
202 "subscription.type.free": "free", 211 "subscription.type.free": "free",
203 "subscription.type.month": "month", 212 "subscription.type.month": "month",
204 "subscription.type.year": "year", 213 "subscription.type.year": "year",
@@ -267,7 +276,10 @@
267 "menu.app.hideOthers": "Hide Others", 276 "menu.app.hideOthers": "Hide Others",
268 "menu.app.unhide": "Unhide", 277 "menu.app.unhide": "Unhide",
269 "menu.app.quit": "Quit", 278 "menu.app.quit": "Quit",
270 "menu.services.addNewService": "Add New Service...", 279 "menu.services.addNewService": "Add New Service",
280 "menu.workspaces": "Workspaces",
281 "menu.workspaces.defaultWorkspace": "All services",
282 "menu.workspaces.addNewWorkspace": "Add New Workspace",
271 "validation.required": "{field} is required", 283 "validation.required": "{field} is required",
272 "validation.email": "{field} is not valid", 284 "validation.email": "{field} is not valid",
273 "validation.url": "{field} is not a valid URL", 285 "validation.url": "{field} is not a valid URL",
diff --git a/src/lib/Menu.js b/src/lib/Menu.js
index c378619ad..1560dd285 100644
--- a/src/lib/Menu.js
+++ b/src/lib/Menu.js
@@ -3,6 +3,8 @@ import { observable, autorun, computed } from 'mobx';
3import { defineMessages } from 'react-intl'; 3import { defineMessages } from 'react-intl';
4 4
5import { isMac, ctrlKey, cmdKey } from '../environment'; 5import { isMac, ctrlKey, cmdKey } from '../environment';
6import { workspacesState } from '../features/workspaces/state';
7import workspaceActions from '../features/workspaces/actions';
6 8
7const { app, Menu, dialog } = remote; 9const { app, Menu, dialog } = remote;
8 10
@@ -179,6 +181,18 @@ const menuItems = defineMessages({
179 id: 'menu.services.addNewService', 181 id: 'menu.services.addNewService',
180 defaultMessage: '!!!Add New Service...', 182 defaultMessage: '!!!Add New Service...',
181 }, 183 },
184 workspaces: {
185 id: 'menu.workspaces',
186 defaultMessage: '!!!Workspaces',
187 },
188 defaultWorkspace: {
189 id: 'menu.workspaces.defaultWorkspace',
190 defaultMessage: '!!!Default',
191 },
192 addNewWorkspace: {
193 id: 'menu.workspaces.addNewWorkspace',
194 defaultMessage: '!!!Add New Workspace...',
195 },
182}); 196});
183 197
184function getActiveWebview() { 198function getActiveWebview() {
@@ -266,6 +280,10 @@ const _templateFactory = intl => [
266 submenu: [], 280 submenu: [],
267 }, 281 },
268 { 282 {
283 label: intl.formatMessage(menuItems.workspaces),
284 submenu: [],
285 },
286 {
269 label: intl.formatMessage(menuItems.window), 287 label: intl.formatMessage(menuItems.window),
270 role: 'window', 288 role: 'window',
271 submenu: [ 289 submenu: [
@@ -499,7 +517,9 @@ export default class FranzMenu {
499 } 517 }
500 518
501 _build() { 519 _build() {
502 const serviceTpl = Object.assign([], this.serviceTpl); // need to clone object so we don't modify computed (cached) object 520 // need to clone object so we don't modify computed (cached) object
521 const serviceTpl = Object.assign([], this.serviceTpl);
522 const workspacesMenu = Object.assign([], this.workspacesMenu);
503 523
504 if (window.franz === undefined) { 524 if (window.franz === undefined) {
505 return; 525 return;
@@ -632,7 +652,7 @@ export default class FranzMenu {
632 }, 652 },
633 ); 653 );
634 654
635 tpl[4].submenu.unshift(about, { 655 tpl[5].submenu.unshift(about, {
636 type: 'separator', 656 type: 'separator',
637 }); 657 });
638 } else { 658 } else {
@@ -678,6 +698,8 @@ export default class FranzMenu {
678 tpl[3].submenu = serviceTpl; 698 tpl[3].submenu = serviceTpl;
679 } 699 }
680 700
701 tpl[4].submenu = workspacesMenu;
702
681 this.currentTemplate = tpl; 703 this.currentTemplate = tpl;
682 const menu = Menu.buildFromTemplate(tpl); 704 const menu = Menu.buildFromTemplate(tpl);
683 Menu.setApplicationMenu(menu); 705 Menu.setApplicationMenu(menu);
@@ -701,6 +723,51 @@ export default class FranzMenu {
701 return []; 723 return [];
702 } 724 }
703 725
726 @computed get workspacesMenu() {
727 const { workspaces, activeWorkspace } = workspacesState;
728 const { intl } = window.franz;
729 const menu = [];
730
731 // Add new workspace item:
732 menu.push({
733 label: intl.formatMessage(menuItems.addNewWorkspace),
734 accelerator: `${cmdKey}+Shift+N`,
735 click: () => {
736 this.actions.ui.openSettings({ path: 'workspaces' });
737 },
738 enabled: this.stores.user.isLoggedIn,
739 }, {
740 type: 'separator',
741 });
742
743 // Default workspace
744 menu.push({
745 label: intl.formatMessage(menuItems.defaultWorkspace),
746 accelerator: `${cmdKey}+Alt+1`,
747 type: 'radio',
748 checked: !activeWorkspace,
749 click: () => {
750 workspaceActions.deactivate();
751 },
752 });
753
754 // Workspace items
755 if (this.stores.user.isLoggedIn) {
756 workspaces.forEach((workspace, i) => menu.push({
757 label: workspace.name,
758 accelerator: i < 9 ? `${cmdKey}+Alt+${i + 2}` : null,
759 type: 'radio',
760 checked: activeWorkspace ? workspace.id === activeWorkspace.id : false,
761 click: () => {
762 workspaceActions.activate({ workspace });
763 },
764 }));
765 }
766
767 console.log(menu);
768 return menu;
769 }
770
704 _getServiceName(service) { 771 _getServiceName(service) {
705 if (service.name) { 772 if (service.name) {
706 return service.name; 773 return service.name;
diff --git a/src/stores/FeaturesStore.js b/src/stores/FeaturesStore.js
index d2842083c..b7130904b 100644
--- a/src/stores/FeaturesStore.js
+++ b/src/stores/FeaturesStore.js
@@ -7,6 +7,7 @@ import delayApp from '../features/delayApp';
7import spellchecker from '../features/spellchecker'; 7import spellchecker from '../features/spellchecker';
8import serviceProxy from '../features/serviceProxy'; 8import serviceProxy from '../features/serviceProxy';
9import basicAuth from '../features/basicAuth'; 9import basicAuth from '../features/basicAuth';
10import workspaces from '../features/workspaces';
10import shareFranz from '../features/shareFranz'; 11import shareFranz from '../features/shareFranz';
11 12
12import { DEFAULT_FEATURES_CONFIG } from '../config'; 13import { DEFAULT_FEATURES_CONFIG } from '../config';
@@ -38,7 +39,7 @@ export default class FeaturesStore extends Store {
38 39
39 @computed get features() { 40 @computed get features() {
40 if (this.stores.user.isLoggedIn) { 41 if (this.stores.user.isLoggedIn) {
41 return this.featuresRequest.execute().result || DEFAULT_FEATURES_CONFIG; 42 return Object.assign({}, DEFAULT_FEATURES_CONFIG, this.featuresRequest.execute().result);
42 } 43 }
43 44
44 return DEFAULT_FEATURES_CONFIG; 45 return DEFAULT_FEATURES_CONFIG;
@@ -57,6 +58,7 @@ export default class FeaturesStore extends Store {
57 spellchecker(this.stores, this.actions); 58 spellchecker(this.stores, this.actions);
58 serviceProxy(this.stores, this.actions); 59 serviceProxy(this.stores, this.actions);
59 basicAuth(this.stores, this.actions); 60 basicAuth(this.stores, this.actions);
61 workspaces(this.stores, this.actions);
60 shareFranz(this.stores, this.actions); 62 shareFranz(this.stores, this.actions);
61 } 63 }
62} 64}
diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js
index c63bef196..a86db8103 100644
--- a/src/stores/ServicesStore.js
+++ b/src/stores/ServicesStore.js
@@ -2,7 +2,7 @@ import {
2 action, 2 action,
3 reaction, 3 reaction,
4 computed, 4 computed,
5 observable, 5 observable, runInAction,
6} from 'mobx'; 6} from 'mobx';
7import { debounce, remove } from 'lodash'; 7import { debounce, remove } from 'lodash';
8import ms from 'ms'; 8import ms from 'ms';
@@ -12,6 +12,8 @@ import Request from './lib/Request';
12import CachedRequest from './lib/CachedRequest'; 12import CachedRequest from './lib/CachedRequest';
13import { matchRoute } from '../helpers/routing-helpers'; 13import { matchRoute } from '../helpers/routing-helpers';
14import { gaEvent } from '../lib/analytics'; 14import { gaEvent } from '../lib/analytics';
15import { workspacesState } from '../features/workspaces/state';
16import { filterServicesByActiveWorkspace, getActiveWorkspaceServices } from '../features/workspaces';
15 17
16const debug = require('debug')('Franz:ServiceStore'); 18const debug = require('debug')('Franz:ServiceStore');
17 19
@@ -98,7 +100,6 @@ export default class ServicesStore extends Store {
98 return observable(services.slice().slice().sort((a, b) => a.order - b.order)); 100 return observable(services.slice().slice().sort((a, b) => a.order - b.order));
99 } 101 }
100 } 102 }
101
102 return []; 103 return [];
103 } 104 }
104 105
@@ -107,13 +108,16 @@ export default class ServicesStore extends Store {
107 } 108 }
108 109
109 @computed get allDisplayed() { 110 @computed get allDisplayed() {
110 return this.stores.settings.all.app.showDisabledServices ? this.all : this.enabled; 111 const services = this.stores.settings.all.app.showDisabledServices ? this.all : this.enabled;
112 return filterServicesByActiveWorkspace(services);
111 } 113 }
112 114
113 // This is just used to avoid unnecessary rerendering of resource-heavy webviews 115 // This is just used to avoid unnecessary rerendering of resource-heavy webviews
114 @computed get allDisplayedUnordered() { 116 @computed get allDisplayedUnordered() {
117 const { showDisabledServices } = this.stores.settings.all.app;
115 const services = this.allServicesRequest.execute().result || []; 118 const services = this.allServicesRequest.execute().result || [];
116 return this.stores.settings.all.app.showDisabledServices ? services : services.filter(service => service.isEnabled); 119 const filteredServices = showDisabledServices ? services : services.filter(service => service.isEnabled);
120 return getActiveWorkspaceServices(filteredServices);
117 } 121 }
118 122
119 @computed get filtered() { 123 @computed get filtered() {
diff --git a/src/styles/main.scss b/src/styles/main.scss
index 784a04d3d..9ba7f5827 100644
--- a/src/styles/main.scss
+++ b/src/styles/main.scss
@@ -31,6 +31,9 @@ $mdi-font-path: '../node_modules/mdi/fonts';
31@import './invite.scss'; 31@import './invite.scss';
32@import './title-bar.scss'; 32@import './title-bar.scss';
33 33
34// Workspaces legacy css
35@import '../features/workspaces/styles/workspaces-table';
36
34// form 37// form
35@import './input.scss'; 38@import './input.scss';
36@import './radio.scss'; 39@import './radio.scss';