aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/announcements/actions.js10
-rw-r--r--src/features/announcements/api.js33
-rw-r--r--src/features/announcements/components/AnnouncementScreen.js286
-rw-r--r--src/features/announcements/index.js36
-rw-r--r--src/features/announcements/store.js144
-rwxr-xr-xsrc/features/settingsWS/actions.js10
-rwxr-xr-xsrc/features/settingsWS/index.js29
-rwxr-xr-xsrc/features/settingsWS/state.js13
-rwxr-xr-xsrc/features/settingsWS/store.js130
-rw-r--r--src/features/shareFranz/index.js2
-rw-r--r--src/features/utils/ActionBinding.js29
-rw-r--r--src/features/utils/FeatureStore.js47
-rw-r--r--src/features/utils/FeatureStore.test.js92
-rw-r--r--src/features/workspaces/components/CreateWorkspaceForm.js4
-rw-r--r--src/features/workspaces/components/EditWorkspaceForm.js41
-rw-r--r--src/features/workspaces/components/WorkspacesDashboard.js38
-rw-r--r--src/features/workspaces/store.js89
17 files changed, 981 insertions, 52 deletions
diff --git a/src/features/announcements/actions.js b/src/features/announcements/actions.js
new file mode 100644
index 000000000..bab496314
--- /dev/null
+++ b/src/features/announcements/actions.js
@@ -0,0 +1,10 @@
1import PropTypes from 'prop-types';
2import { createActionsFromDefinitions } from '../../actions/lib/actions';
3
4export const announcementActions = createActionsFromDefinitions({
5 show: {
6 targetVersion: PropTypes.string,
7 },
8}, PropTypes.checkPropTypes);
9
10export default announcementActions;
diff --git a/src/features/announcements/api.js b/src/features/announcements/api.js
new file mode 100644
index 000000000..a581bd8de
--- /dev/null
+++ b/src/features/announcements/api.js
@@ -0,0 +1,33 @@
1import { remote } from 'electron';
2import Request from '../../stores/lib/Request';
3import { API, API_VERSION } from '../../environment';
4
5const debug = require('debug')('Franz:feature:announcements:api');
6
7export const announcementsApi = {
8 async getCurrentVersion() {
9 debug('getting current version of electron app');
10 return Promise.resolve(remote.app.getVersion());
11 },
12
13 async getChangelog(version) {
14 debug('fetching release changelog from Github');
15 const url = `https://api.github.com/repos/meetfranz/franz/releases/tags/v${version}`;
16 const request = await window.fetch(url, { method: 'GET' });
17 if (!request.ok) return null;
18 const data = await request.json();
19 return data.body;
20 },
21
22 async getAnnouncement(version) {
23 debug('fetching release announcement from api');
24 const url = `${API}/${API_VERSION}/announcements/${version}`;
25 const response = await window.fetch(url, { method: 'GET' });
26 if (!response.ok) return null;
27 return response.json();
28 },
29};
30
31export const getCurrentVersionRequest = new Request(announcementsApi, 'getCurrentVersion');
32export const getChangelogRequest = new Request(announcementsApi, 'getChangelog');
33export const getAnnouncementRequest = new Request(announcementsApi, 'getAnnouncement');
diff --git a/src/features/announcements/components/AnnouncementScreen.js b/src/features/announcements/components/AnnouncementScreen.js
new file mode 100644
index 000000000..e7c5fe395
--- /dev/null
+++ b/src/features/announcements/components/AnnouncementScreen.js
@@ -0,0 +1,286 @@
1import React, { Component } from 'react';
2import marked from 'marked';
3import PropTypes from 'prop-types';
4import { inject, observer } from 'mobx-react';
5import { defineMessages, intlShape } from 'react-intl';
6import injectSheet from 'react-jss';
7import { Button } from '@meetfranz/forms';
8
9import { announcementsStore } from '../index';
10import UIStore from '../../../stores/UIStore';
11import { gaEvent } from '../../../lib/analytics';
12
13const renderer = new marked.Renderer();
14
15renderer.link = (href, title, text) => `<a target="_blank" href="${href}" title="${title}">${text}</a>`;
16
17const markedOptions = { sanitize: true, renderer };
18
19const messages = defineMessages({
20 headline: {
21 id: 'feature.announcements.changelog.headline',
22 defaultMessage: '!!!Changes in Franz {version}',
23 },
24});
25
26const smallScreen = '1000px';
27
28const styles = theme => ({
29 container: {
30 background: theme.colorBackground,
31 position: 'absolute',
32 top: 0,
33 zIndex: 140,
34 width: '100%',
35 height: '100%',
36 overflowY: 'auto',
37 },
38 headline: {
39 color: theme.colorHeadline,
40 margin: [25, 0, 40],
41 // 'max-width': 500,
42 'text-align': 'center',
43 'line-height': '1.3em',
44 },
45 announcement: {
46 height: 'auto',
47
48 [`@media(min-width: ${smallScreen})`]: {
49 display: 'flex',
50 flexDirection: 'column',
51 justifyContent: 'center',
52 height: '100vh',
53 },
54 },
55 main: {
56 display: 'flex',
57 flexDirection: 'column',
58 flexGrow: 1,
59 justifyContent: 'center',
60
61 '& h1': {
62 margin: [40, 0, 15],
63 fontSize: 70,
64 color: theme.styleTypes.primary.accent,
65 textAlign: 'center',
66
67 [`@media(min-width: ${smallScreen})`]: {
68 marginTop: 0,
69 },
70 },
71 '& h2': {
72 fontSize: 30,
73 fontWeight: 300,
74 color: theme.colorText,
75 textAlign: 'center',
76 marginBottom: 60,
77 },
78 },
79 mainBody: {
80 display: 'flex',
81 flexDirection: 'column',
82 alignItems: 'center',
83 width: 'calc(100% - 80px)',
84 height: 'auto',
85 margin: '0 auto',
86 [`@media(min-width: ${smallScreen})`]: {
87 flexDirection: 'row',
88 justifyContent: 'center',
89 },
90 },
91 mainImage: {
92 minWidth: 250,
93 maxWidth: 400,
94 margin: '0 auto',
95 marginBottom: 40,
96 '& img': {
97 width: '100%',
98 },
99 [`@media(min-width: ${smallScreen})`]: {
100 margin: 0,
101 },
102 },
103 mainText: {
104 height: 'auto',
105 maxWidth: 600,
106 textAlign: 'center',
107 '& p': {
108 lineHeight: '1.5em',
109 },
110 [`@media(min-width: ${smallScreen})`]: {
111 textAlign: 'left',
112 },
113 },
114 mainCtaButton: {
115 textAlign: 'center',
116 marginTop: 40,
117 [`@media(min-width: ${smallScreen})`]: {
118 textAlign: 'left',
119 },
120 },
121 spotlight: {
122 height: 'auto',
123 background: theme.announcements.spotlight.background,
124 padding: [40, 0],
125 marginTop: 80,
126 [`@media(min-width: ${smallScreen})`]: {
127 marginTop: 0,
128 justifyContent: 'center',
129 alignItems: 'flex-start',
130 display: 'flex',
131 flexDirection: 'row',
132 },
133 },
134 spotlightTopicContainer: {
135 textAlign: 'center',
136 marginBottom: 20,
137
138 [`@media(min-width: ${smallScreen})`]: {
139 marginBottom: 0,
140 minWidth: 250,
141 maxWidth: 330,
142 width: '100%',
143 textAlign: 'right',
144 marginRight: 60,
145 },
146 },
147 spotlightContentContainer: {
148 textAlign: 'center',
149 [`@media(min-width: ${smallScreen})`]: {
150 height: 'auto',
151 maxWidth: 600,
152 paddingRight: 40,
153 textAlign: 'left',
154 },
155 '& p': {
156 lineHeight: '1.5em',
157 },
158 },
159 spotlightTopic: {
160 fontSize: 20,
161 marginBottom: 5,
162 letterSpacing: 0,
163 fontWeight: 100,
164 },
165 spotlightSubject: {
166 fontSize: 20,
167 },
168 changelog: {
169 padding: [0, 60],
170 maxWidth: 700,
171 margin: [100, 'auto'],
172 height: 'auto',
173
174 '& h3': {
175 fontSize: '24px',
176 margin: '1.5em 0 1em 0',
177 },
178 '& li': {
179 marginBottom: '1em',
180 lineHeight: '1.4em',
181 },
182 '& div': {
183 height: 'auto',
184 },
185 },
186});
187
188
189@inject('stores', 'actions') @injectSheet(styles) @observer
190class AnnouncementScreen extends Component {
191 static propTypes = {
192 classes: PropTypes.object.isRequired,
193 stores: PropTypes.shape({
194 ui: PropTypes.instanceOf(UIStore).isRequired,
195 }).isRequired,
196 };
197
198 static contextTypes = {
199 intl: intlShape,
200 };
201
202 render() {
203 const { classes, stores } = this.props;
204 const { intl } = this.context;
205 const { changelog, announcement } = announcementsStore;
206 const themeImage = stores.ui.isDarkThemeActive ? 'dark' : 'light';
207 return (
208 <div className={classes.container}>
209 {announcement && (
210 <div className={classes.announcement}>
211 <div className={classes.main}>
212 <h1>{announcement.main.headline}</h1>
213 <h2>{announcement.main.subHeadline}</h2>
214 <div className={classes.mainBody}>
215 <div className={classes.mainImage}>
216 <img
217 src={announcement.main.image[themeImage]}
218 alt=""
219 />
220 </div>
221 <div className={classes.mainText}>
222 <div
223 dangerouslySetInnerHTML={{
224 __html: marked(announcement.main.text, markedOptions),
225 }}
226 />
227 <div className={classes.mainCtaButton}>
228 <Button
229 label={announcement.main.cta.label}
230 onClick={() => {
231 const { analytics } = announcement.main.cta;
232 window.location.href = `#${announcement.main.cta.href}`;
233 gaEvent(analytics.category, analytics.action, announcement.main.cta.label);
234 }}
235 />
236 </div>
237 </div>
238 </div>
239 </div>
240 {announcement.spotlight && (
241 <div className={classes.spotlight}>
242 <div className={classes.spotlightTopicContainer}>
243 <h2 className={classes.spotlightTopic}>{announcement.spotlight.title}</h2>
244 <h3 className={classes.spotlightSubject}>{announcement.spotlight.subject}</h3>
245 </div>
246 <div className={classes.spotlightContentContainer}>
247 <div
248 dangerouslySetInnerHTML={{
249 __html: marked(announcement.spotlight.text, markedOptions),
250 }}
251 />
252 <div className={classes.mainCtaButton}>
253 <Button
254 label={announcement.spotlight.cta.label}
255 onClick={() => {
256 const { analytics } = announcement.spotlight.cta;
257 window.location.href = `#${announcement.spotlight.cta.href}`;
258 gaEvent(analytics.category, analytics.action, announcement.spotlight.cta.label);
259 }}
260 />
261 </div>
262 </div>
263 </div>
264 )}
265 </div>
266 )}
267 {changelog && (
268 <div className={classes.changelog}>
269 <h1 className={classes.headline}>
270 {intl.formatMessage(messages.headline, {
271 version: announcementsStore.targetVersion,
272 })}
273 </h1>
274 <div
275 dangerouslySetInnerHTML={{
276 __html: marked(changelog, markedOptions),
277 }}
278 />
279 </div>
280 )}
281 </div>
282 );
283 }
284}
285
286export default AnnouncementScreen;
diff --git a/src/features/announcements/index.js b/src/features/announcements/index.js
new file mode 100644
index 000000000..f14e7c9a5
--- /dev/null
+++ b/src/features/announcements/index.js
@@ -0,0 +1,36 @@
1import { reaction } from 'mobx';
2import { AnnouncementsStore } from './store';
3
4const debug = require('debug')('Franz:feature:announcements');
5
6export const GA_CATEGORY_ANNOUNCEMENTS = 'Announcements';
7
8export const announcementsStore = new AnnouncementsStore();
9
10export const ANNOUNCEMENTS_ROUTES = {
11 TARGET: '/announcements/:id',
12};
13
14export default function initAnnouncements(stores, actions) {
15 // const { features } = stores;
16
17 // Toggle workspace feature
18 reaction(
19 () => (
20 true
21 // features.features.isAnnouncementsEnabled
22 ),
23 (isEnabled) => {
24 if (isEnabled) {
25 debug('Initializing `announcements` feature');
26 announcementsStore.start(stores, actions);
27 } else if (announcementsStore.isFeatureActive) {
28 debug('Disabling `announcements` feature');
29 announcementsStore.stop();
30 }
31 },
32 {
33 fireImmediately: true,
34 },
35 );
36}
diff --git a/src/features/announcements/store.js b/src/features/announcements/store.js
new file mode 100644
index 000000000..ad78a0979
--- /dev/null
+++ b/src/features/announcements/store.js
@@ -0,0 +1,144 @@
1import {
2 action,
3 computed,
4 observable,
5} from 'mobx';
6import semver from 'semver';
7import localStorage from 'mobx-localstorage';
8
9import { FeatureStore } from '../utils/FeatureStore';
10import { ANNOUNCEMENTS_ROUTES, GA_CATEGORY_ANNOUNCEMENTS } from '.';
11import { getAnnouncementRequest, getChangelogRequest, getCurrentVersionRequest } from './api';
12import { announcementActions } from './actions';
13import { createActionBindings } from '../utils/ActionBinding';
14import { createReactions } from '../../stores/lib/Reaction';
15import { gaEvent } from '../../lib/analytics';
16import { matchRoute } from '../../helpers/routing-helpers';
17
18const LOCAL_STORAGE_KEY = 'announcements';
19
20const debug = require('debug')('Franz:feature:announcements:store');
21
22export class AnnouncementsStore extends FeatureStore {
23 @observable targetVersion = null;
24
25 @observable isFeatureActive = false;
26
27 @computed get changelog() {
28 return getChangelogRequest.result;
29 }
30
31 @computed get announcement() {
32 if (!this.stores || !getAnnouncementRequest.result) return null;
33 const { locale, defaultLocale } = this.stores.app;
34 const announcement = getAnnouncementRequest.result;
35 // User locale
36 if (announcement[locale]) return announcement[locale];
37 // Default locale
38 if (announcement[defaultLocale]) return announcement[defaultLocale];
39 // No locales specified
40 return announcement;
41 }
42
43 @computed get areNewsAvailable() {
44 const isChangelogAvailable = getChangelogRequest.wasExecuted && !!this.changelog;
45 const isAnnouncementAvailable = getAnnouncementRequest.wasExecuted && !!this.announcement;
46 return isChangelogAvailable || isAnnouncementAvailable;
47 }
48
49 @computed get settings() {
50 return localStorage.getItem(LOCAL_STORAGE_KEY) || {};
51 }
52
53 @computed get lastSeenAnnouncementVersion() {
54 return this.settings.lastSeenAnnouncementVersion || null;
55 }
56
57 @computed get currentVersion() {
58 return getCurrentVersionRequest.result;
59 }
60
61 @computed get isNewUser() {
62 return this.stores.settings.stats.appStarts <= 1;
63 }
64
65 async start(stores, actions) {
66 debug('AnnouncementsStore::start');
67 this.stores = stores;
68 this.actions = actions;
69 getCurrentVersionRequest.execute();
70
71 this._registerActions(createActionBindings([
72 [announcementActions.show, this._showAnnouncement],
73 ]));
74
75 this._reactions = createReactions([
76 this._showAnnouncementOnRouteMatch,
77 this._showAnnouncementToUsersWhoUpdatedApp,
78 this._fetchAnnouncements,
79 ]);
80 this._registerReactions(this._reactions);
81 this.isFeatureActive = true;
82 }
83
84 stop() {
85 super.stop();
86 debug('AnnouncementsStore::stop');
87 this.isFeatureActive = false;
88 }
89
90 // ======= HELPERS ======= //
91
92 _updateSettings = (changes) => {
93 localStorage.setItem(LOCAL_STORAGE_KEY, {
94 ...this.settings,
95 ...changes,
96 });
97 };
98
99 // ======= ACTIONS ======= //
100
101 @action _showAnnouncement = ({ targetVersion } = {}) => {
102 const { router } = this.stores;
103 this.targetVersion = targetVersion || this.currentVersion;
104 this._updateSettings({
105 lastSeenAnnouncementVersion: this.currentVersion,
106 });
107 const targetRoute = `/announcements/${this.targetVersion}`;
108 if (router.location.pathname !== targetRoute) {
109 this.stores.router.push(targetRoute);
110 }
111 gaEvent(GA_CATEGORY_ANNOUNCEMENTS, 'show');
112 };
113
114 // ======= REACTIONS ========
115
116 _showAnnouncementToUsersWhoUpdatedApp = () => {
117 const { announcement, isNewUser } = this;
118 // Check if there is an announcement and don't show announcements to new users
119 if (!announcement || isNewUser) return;
120
121 // Check if the user has already used current version (= has seen the announcement)
122 const { currentVersion, lastSeenAnnouncementVersion } = this;
123 if (semver.gt(currentVersion, lastSeenAnnouncementVersion || '0.0.0')) {
124 debug(`${currentVersion} > ${lastSeenAnnouncementVersion}: announcement is shown`);
125 this._showAnnouncement();
126 }
127 };
128
129 _fetchAnnouncements = () => {
130 const targetVersion = this.targetVersion || this.currentVersion;
131 if (!targetVersion) return;
132 getChangelogRequest.reset().execute(targetVersion);
133 getAnnouncementRequest.reset().execute(targetVersion);
134 };
135
136 _showAnnouncementOnRouteMatch = () => {
137 const { router } = this.stores;
138 const match = matchRoute(ANNOUNCEMENTS_ROUTES.TARGET, router.location.pathname);
139 if (match) {
140 const targetVersion = match.id;
141 this._showAnnouncement({ targetVersion });
142 }
143 }
144}
diff --git a/src/features/settingsWS/actions.js b/src/features/settingsWS/actions.js
new file mode 100755
index 000000000..631670c8a
--- /dev/null
+++ b/src/features/settingsWS/actions.js
@@ -0,0 +1,10 @@
1import PropTypes from 'prop-types';
2import { createActionsFromDefinitions } from '../../actions/lib/actions';
3
4export const settingsWSActions = createActionsFromDefinitions({
5 greet: {
6 name: PropTypes.string.isRequired,
7 },
8}, PropTypes.checkPropTypes);
9
10export default settingsWSActions;
diff --git a/src/features/settingsWS/index.js b/src/features/settingsWS/index.js
new file mode 100755
index 000000000..2064d2973
--- /dev/null
+++ b/src/features/settingsWS/index.js
@@ -0,0 +1,29 @@
1import { reaction } from 'mobx';
2import { SettingsWSStore } from './store';
3
4const debug = require('debug')('Franz:feature:settingsWS');
5
6export const settingsStore = new SettingsWSStore();
7
8export default function initSettingsWebSocket(stores, actions) {
9 const { features } = stores;
10
11 // Toggle SettingsWebSocket feature
12 reaction(
13 () => (
14 features.features.isSettingsWSEnabled
15 ),
16 (isEnabled) => {
17 if (isEnabled) {
18 debug('Initializing `settingsWS` feature');
19 settingsStore.start(stores, actions);
20 } else if (settingsStore) {
21 debug('Disabling `settingsWS` feature');
22 settingsStore.stop();
23 }
24 },
25 {
26 fireImmediately: true,
27 },
28 );
29}
diff --git a/src/features/settingsWS/state.js b/src/features/settingsWS/state.js
new file mode 100755
index 000000000..7b16b2b6e
--- /dev/null
+++ b/src/features/settingsWS/state.js
@@ -0,0 +1,13 @@
1import { observable } from 'mobx';
2
3const defaultState = {
4 isFeatureActive: false,
5};
6
7export const settingsWSState = observable(defaultState);
8
9export function resetState() {
10 Object.assign(settingsWSState, defaultState);
11}
12
13export default settingsWSState;
diff --git a/src/features/settingsWS/store.js b/src/features/settingsWS/store.js
new file mode 100755
index 000000000..167a70d10
--- /dev/null
+++ b/src/features/settingsWS/store.js
@@ -0,0 +1,130 @@
1import { observable } from 'mobx';
2import WebSocket from 'ws';
3import ms from 'ms';
4
5import { FeatureStore } from '../utils/FeatureStore';
6import { createReactions } from '../../stores/lib/Reaction';
7import { WS_API } from '../../environment';
8
9const debug = require('debug')('Franz:feature:settingsWS:store');
10
11export class SettingsWSStore extends FeatureStore {
12 stores = null;
13
14 actions = null;
15
16 ws = null;
17
18 pingTimeout = null;
19
20 reconnectTimeout = null;
21
22 @observable connected = false;
23
24 start(stores, actions) {
25 this.stores = stores;
26 this.actions = actions;
27
28 this._registerReactions(createReactions([
29 this._initialize.bind(this),
30 this._reconnect.bind(this),
31 this._close.bind(this),
32 ]));
33 }
34
35 connect() {
36 try {
37 const wsURL = `${WS_API}/ws/${this.stores.user.data.id}`;
38 debug('Setting up WebSocket to', wsURL);
39
40 this.ws = new WebSocket(wsURL);
41
42 this.ws.on('open', () => {
43 debug('Opened WebSocket');
44 this.send({
45 action: 'authorize',
46 token: this.stores.user.authToken,
47 });
48
49 this.connected = true;
50
51 this.heartbeat();
52 });
53
54 this.ws.on('message', (data) => {
55 const resp = JSON.parse(data);
56 debug('Received message', resp);
57
58 if (resp.id) {
59 this.stores.user.getUserInfoRequest.patch((result) => {
60 if (!result) return;
61
62 debug('Patching user object with new values');
63 Object.assign(result, resp);
64 });
65 }
66 });
67
68 this.ws.on('ping', this.heartbeat.bind(this));
69 } catch (err) {
70 console.err(err);
71 }
72 }
73
74 heartbeat() {
75 debug('Heartbeat');
76 clearTimeout(this.pingTimeout);
77
78 this.pingTimeout = setTimeout(() => {
79 debug('Terminating connection, reconnecting in 35');
80 this.ws.terminate();
81
82 this.connected = false;
83 }, ms('35s'));
84 }
85
86 send(data) {
87 if (this.ws && this.ws.readyState === 1) {
88 this.ws.send(JSON.stringify(data));
89 debug('Sending data', data);
90 } else {
91 debug('WebSocket is not initialized');
92 }
93 }
94
95 // Reactions
96
97 _initialize() {
98 if (this.stores.user.data.id && !this.ws) {
99 this.connect();
100 }
101 }
102
103 _reconnect() {
104 if (!this.connected) {
105 debug('Trying to reconnect in 30s');
106 this.reconnectTimeout = setInterval(() => {
107 debug('Trying to reconnect');
108 this.connect();
109 }, ms('30s'));
110 } else {
111 debug('Clearing reconnect interval');
112 clearInterval(this.reconnectTimeout);
113 }
114 }
115
116 _close() {
117 if (!this.stores.user.isLoggedIn) {
118 debug('Stopping reactions');
119 this._stopReactions();
120
121 if (this.ws) {
122 debug('Terminating connection');
123 this.ws.terminate();
124 this.ws = null;
125 }
126 }
127 }
128}
129
130export default SettingsWSStore;
diff --git a/src/features/shareFranz/index.js b/src/features/shareFranz/index.js
index 3a8ec95d3..87deacef4 100644
--- a/src/features/shareFranz/index.js
+++ b/src/features/shareFranz/index.js
@@ -35,7 +35,7 @@ export default function initialize(stores) {
35 () => stores.user.isLoggedIn, 35 () => stores.user.isLoggedIn,
36 () => { 36 () => {
37 setTimeout(() => { 37 setTimeout(() => {
38 if (stores.settings.stats.appStarts % 30 === 0) { 38 if (stores.settings.stats.appStarts % 50 === 0) {
39 if (delayAppState.isDelayAppScreenVisible) { 39 if (delayAppState.isDelayAppScreenVisible) {
40 debug('Delaying share modal by 5 minutes'); 40 debug('Delaying share modal by 5 minutes');
41 setTimeout(() => showModal(), ms('5m')); 41 setTimeout(() => showModal(), ms('5m'));
diff --git a/src/features/utils/ActionBinding.js b/src/features/utils/ActionBinding.js
new file mode 100644
index 000000000..497aa071b
--- /dev/null
+++ b/src/features/utils/ActionBinding.js
@@ -0,0 +1,29 @@
1export default class ActionBinding {
2 action;
3
4 isActive = false;
5
6 constructor(action) {
7 this.action = action;
8 }
9
10 start() {
11 if (!this.isActive) {
12 const { action } = this;
13 action[0].listen(action[1]);
14 this.isActive = true;
15 }
16 }
17
18 stop() {
19 if (this.isActive) {
20 const { action } = this;
21 action[0].off(action[1]);
22 this.isActive = false;
23 }
24 }
25}
26
27export const createActionBindings = actions => (
28 actions.map(a => new ActionBinding(a))
29);
diff --git a/src/features/utils/FeatureStore.js b/src/features/utils/FeatureStore.js
index 66b66a104..0bc10e176 100644
--- a/src/features/utils/FeatureStore.js
+++ b/src/features/utils/FeatureStore.js
@@ -1,21 +1,40 @@
1import Reaction from '../../stores/lib/Reaction';
2
3export class FeatureStore { 1export class FeatureStore {
4 _actions = null; 2 _actions = [];
3
4 _reactions = [];
5
6 stop() {
7 this._stopActions();
8 this._stopReactions();
9 }
10
11 // ACTIONS
12
13 _registerActions(actions) {
14 this._actions = actions;
15 this._startActions();
16 }
5 17
6 _reactions = null; 18 _startActions(actions = this._actions) {
19 actions.forEach(a => a.start());
20 }
21
22 _stopActions(actions = this._actions) {
23 actions.forEach(a => a.stop());
24 }
25
26 // REACTIONS
27
28 _registerReactions(reactions) {
29 this._reactions = reactions;
30 this._startReactions();
31 }
7 32
8 _listenToActions(actions) { 33 _startReactions(reactions = this._reactions) {
9 if (this._actions) this._actions.forEach(a => a[0].off(a[1])); 34 reactions.forEach(r => r.start());
10 this._actions = [];
11 actions.forEach(a => this._actions.push(a));
12 this._actions.forEach(a => a[0].listen(a[1]));
13 } 35 }
14 36
15 _startReactions(reactions) { 37 _stopReactions(reactions = this._reactions) {
16 if (this._reactions) this._reactions.forEach(r => r.stop()); 38 reactions.forEach(r => r.stop());
17 this._reactions = [];
18 reactions.forEach(r => this._reactions.push(new Reaction(r)));
19 this._reactions.forEach(r => r.start());
20 } 39 }
21} 40}
diff --git a/src/features/utils/FeatureStore.test.js b/src/features/utils/FeatureStore.test.js
new file mode 100644
index 000000000..92308bf52
--- /dev/null
+++ b/src/features/utils/FeatureStore.test.js
@@ -0,0 +1,92 @@
1import PropTypes from 'prop-types';
2import { observable } from 'mobx';
3import { FeatureStore } from './FeatureStore';
4import { createActionsFromDefinitions } from '../../actions/lib/actions';
5import { createActionBindings } from './ActionBinding';
6import { createReactions } from '../../stores/lib/Reaction';
7
8const actions = createActionsFromDefinitions({
9 countUp: {},
10}, PropTypes.checkPropTypes);
11
12class TestFeatureStore extends FeatureStore {
13 @observable count = 0;
14
15 reactionInvokedCount = 0;
16
17 start() {
18 this._registerActions(createActionBindings([
19 [actions.countUp, this._countUp],
20 ]));
21 this._registerReactions(createReactions([
22 this._countReaction,
23 ]));
24 }
25
26 _countUp = () => {
27 this.count += 1;
28 };
29
30 _countReaction = () => {
31 this.reactionInvokedCount += 1;
32 }
33}
34
35describe('FeatureStore', () => {
36 let store = null;
37
38 beforeEach(() => {
39 store = new TestFeatureStore();
40 });
41
42 describe('registering actions', () => {
43 it('starts the actions', () => {
44 store.start();
45 actions.countUp();
46 expect(store.count).toBe(1);
47 });
48 it('starts the reactions', () => {
49 store.start();
50 actions.countUp();
51 expect(store.reactionInvokedCount).toBe(1);
52 });
53 });
54
55 describe('stopping the store', () => {
56 it('stops the actions', () => {
57 store.start();
58 actions.countUp();
59 store.stop();
60 actions.countUp();
61 expect(store.count).toBe(1);
62 });
63 it('stops the reactions', () => {
64 store.start();
65 actions.countUp();
66 store.stop();
67 store.count += 1;
68 expect(store.reactionInvokedCount).toBe(1);
69 });
70 });
71
72 describe('toggling the store', () => {
73 it('restarts the actions correctly', () => {
74 store.start();
75 actions.countUp();
76 store.stop();
77 actions.countUp();
78 store.start();
79 actions.countUp();
80 expect(store.count).toBe(2);
81 });
82 it('restarts the reactions correctly', () => {
83 store.start();
84 actions.countUp();
85 store.stop();
86 actions.countUp();
87 store.start();
88 actions.countUp();
89 expect(store.count).toBe(2);
90 });
91 });
92});
diff --git a/src/features/workspaces/components/CreateWorkspaceForm.js b/src/features/workspaces/components/CreateWorkspaceForm.js
index 2c00ea63c..cddbb2b04 100644
--- a/src/features/workspaces/components/CreateWorkspaceForm.js
+++ b/src/features/workspaces/components/CreateWorkspaceForm.js
@@ -7,7 +7,7 @@ import injectSheet from 'react-jss';
7import Form from '../../../lib/Form'; 7import Form from '../../../lib/Form';
8import { required } from '../../../helpers/validation-helpers'; 8import { required } from '../../../helpers/validation-helpers';
9import { gaEvent } from '../../../lib/analytics'; 9import { gaEvent } from '../../../lib/analytics';
10import { GA_CATEGORY_WORKSPACES } from '../index'; 10import { GA_CATEGORY_WORKSPACES, workspaceStore } from '../index';
11 11
12const messages = defineMessages({ 12const messages = defineMessages({
13 submitButton: { 13 submitButton: {
@@ -82,7 +82,7 @@ class CreateWorkspaceForm extends Component {
82 {...form.$('name').bind()} 82 {...form.$('name').bind()}
83 showLabel={false} 83 showLabel={false}
84 onEnterKey={this.submitForm.bind(this, form)} 84 onEnterKey={this.submitForm.bind(this, form)}
85 focus 85 focus={workspaceStore.isUserAllowedToUseFeature}
86 /> 86 />
87 <Button 87 <Button
88 className={classes.submitButton} 88 className={classes.submitButton}
diff --git a/src/features/workspaces/components/EditWorkspaceForm.js b/src/features/workspaces/components/EditWorkspaceForm.js
index bba4485ff..e602ebd5a 100644
--- a/src/features/workspaces/components/EditWorkspaceForm.js
+++ b/src/features/workspaces/components/EditWorkspaceForm.js
@@ -1,4 +1,4 @@
1import React, { Component } from 'react'; 1import React, { Component, Fragment } from 'react';
2import PropTypes from 'prop-types'; 2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react'; 3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl'; 4import { defineMessages, intlShape } from 'react-intl';
@@ -36,6 +36,14 @@ const messages = defineMessages({
36 id: 'settings.workspace.form.servicesInWorkspaceHeadline', 36 id: 'settings.workspace.form.servicesInWorkspaceHeadline',
37 defaultMessage: '!!!Services in this Workspace', 37 defaultMessage: '!!!Services in this Workspace',
38 }, 38 },
39 noServicesAdded: {
40 id: 'settings.services.noServicesAdded',
41 defaultMessage: '!!!You haven\'t added any services yet.',
42 },
43 discoverServices: {
44 id: 'settings.services.discoverServices',
45 defaultMessage: '!!!Discover services',
46 },
39}); 47});
40 48
41const styles = () => ({ 49const styles = () => ({
@@ -150,14 +158,29 @@ class EditWorkspaceForm extends Component {
150 </div> 158 </div>
151 <h2>{intl.formatMessage(messages.servicesInWorkspaceHeadline)}</h2> 159 <h2>{intl.formatMessage(messages.servicesInWorkspaceHeadline)}</h2>
152 <div className={classes.serviceList}> 160 <div className={classes.serviceList}>
153 {services.map(s => ( 161 {services.length === 0 ? (
154 <WorkspaceServiceListItem 162 <div className="align-middle settings__empty-state">
155 key={s.id} 163 {/* ===== Empty state ===== */}
156 service={s} 164 <p className="settings__empty-text">
157 isInWorkspace={workspaceServices.includes(s.id)} 165 <span className="emoji">
158 onToggle={() => this.toggleService(s)} 166 <img src="./assets/images/emoji/sad.png" alt="" />
159 /> 167 </span>
160 ))} 168 {intl.formatMessage(messages.noServicesAdded)}
169 </p>
170 <Link to="/settings/recipes" className="button">{intl.formatMessage(messages.discoverServices)}</Link>
171 </div>
172 ) : (
173 <Fragment>
174 {services.map(s => (
175 <WorkspaceServiceListItem
176 key={s.id}
177 service={s}
178 isInWorkspace={workspaceServices.includes(s.id)}
179 onToggle={() => this.toggleService(s)}
180 />
181 ))}
182 </Fragment>
183 )}
161 </div> 184 </div>
162 </div> 185 </div>
163 <div className="settings__controls"> 186 <div className="settings__controls">
diff --git a/src/features/workspaces/components/WorkspacesDashboard.js b/src/features/workspaces/components/WorkspacesDashboard.js
index dd4381a15..09c98ab8c 100644
--- a/src/features/workspaces/components/WorkspacesDashboard.js
+++ b/src/features/workspaces/components/WorkspacesDashboard.js
@@ -170,18 +170,32 @@ class WorkspacesDashboard extends Component {
170 {intl.formatMessage(messages.workspacesRequestFailed)} 170 {intl.formatMessage(messages.workspacesRequestFailed)}
171 </Infobox> 171 </Infobox>
172 ) : ( 172 ) : (
173 <table className={classes.table}> 173 <Fragment>
174 {/* ===== Workspaces list ===== */} 174 {workspaces.length === 0 ? (
175 <tbody> 175 <div className="align-middle settings__empty-state">
176 {workspaces.map(workspace => ( 176 {/* ===== Workspaces empty state ===== */}
177 <WorkspaceItem 177 <p className="settings__empty-text">
178 key={workspace.id} 178 <span className="emoji">
179 workspace={workspace} 179 <img src="./assets/images/emoji/sad.png" alt="" />
180 onItemClick={w => onWorkspaceClick(w)} 180 </span>
181 /> 181 {intl.formatMessage(messages.noServicesAdded)}
182 ))} 182 </p>
183 </tbody> 183 </div>
184 </table> 184 ) : (
185 <table className={classes.table}>
186 {/* ===== Workspaces list ===== */}
187 <tbody>
188 {workspaces.map(workspace => (
189 <WorkspaceItem
190 key={workspace.id}
191 workspace={workspace}
192 onItemClick={w => onWorkspaceClick(w)}
193 />
194 ))}
195 </tbody>
196 </table>
197 )}
198 </Fragment>
185 )} 199 )}
186 </Fragment> 200 </Fragment>
187 )} 201 )}
diff --git a/src/features/workspaces/store.js b/src/features/workspaces/store.js
index ea601700e..51a7f3651 100644
--- a/src/features/workspaces/store.js
+++ b/src/features/workspaces/store.js
@@ -14,6 +14,8 @@ import {
14 updateWorkspaceRequest, 14 updateWorkspaceRequest,
15} from './api'; 15} from './api';
16import { WORKSPACES_ROUTES } from './index'; 16import { WORKSPACES_ROUTES } from './index';
17import { createReactions } from '../../stores/lib/Reaction';
18import { createActionBindings } from '../utils/ActionBinding';
17 19
18const debug = require('debug')('Franz:feature:workspaces:store'); 20const debug = require('debug')('Franz:feature:workspaces:store');
19 21
@@ -51,37 +53,78 @@ export default class WorkspacesStore extends FeatureStore {
51 return getUserWorkspacesRequest.wasExecuted && this.workspaces.length > 0; 53 return getUserWorkspacesRequest.wasExecuted && this.workspaces.length > 0;
52 } 54 }
53 55
56 @computed get isUserAllowedToUseFeature() {
57 return !this.isPremiumUpgradeRequired;
58 }
59
60 @computed get isAnyWorkspaceActive() {
61 return !!this.activeWorkspace;
62 }
63
64 // ========== PRIVATE PROPERTIES ========= //
65
66 _wasDrawerOpenBeforeSettingsRoute = null;
67
68 _freeUserActions = [];
69
70 _premiumUserActions = [];
71
72 _allActions = [];
73
74 _freeUserReactions = [];
75
76 _premiumUserReactions = [];
77
78 _allReactions = [];
79
80 // ========== PUBLIC API ========= //
81
54 start(stores, actions) { 82 start(stores, actions) {
55 debug('WorkspacesStore::start'); 83 debug('WorkspacesStore::start');
56 this.stores = stores; 84 this.stores = stores;
57 this.actions = actions; 85 this.actions = actions;
58 86
59 this._listenToActions([ 87 // ACTIONS
88
89 this._freeUserActions = createActionBindings([
90 [workspaceActions.toggleWorkspaceDrawer, this._toggleWorkspaceDrawer],
91 [workspaceActions.openWorkspaceSettings, this._openWorkspaceSettings],
92 ]);
93 this._premiumUserActions = createActionBindings([
60 [workspaceActions.edit, this._edit], 94 [workspaceActions.edit, this._edit],
61 [workspaceActions.create, this._create], 95 [workspaceActions.create, this._create],
62 [workspaceActions.delete, this._delete], 96 [workspaceActions.delete, this._delete],
63 [workspaceActions.update, this._update], 97 [workspaceActions.update, this._update],
64 [workspaceActions.activate, this._setActiveWorkspace], 98 [workspaceActions.activate, this._setActiveWorkspace],
65 [workspaceActions.deactivate, this._deactivateActiveWorkspace], 99 [workspaceActions.deactivate, this._deactivateActiveWorkspace],
66 [workspaceActions.toggleWorkspaceDrawer, this._toggleWorkspaceDrawer],
67 [workspaceActions.openWorkspaceSettings, this._openWorkspaceSettings],
68 ]); 100 ]);
101 this._allActions = this._freeUserActions.concat(this._premiumUserActions);
102 this._registerActions(this._allActions);
69 103
70 this._startReactions([ 104 // REACTIONS
71 this._setWorkspaceBeingEditedReaction, 105
72 this._setActiveServiceOnWorkspaceSwitchReaction, 106 this._freeUserReactions = createReactions([
107 this._stopPremiumActionsAndReactions,
108 this._openDrawerWithSettingsReaction,
73 this._setFeatureEnabledReaction, 109 this._setFeatureEnabledReaction,
74 this._setIsPremiumFeatureReaction, 110 this._setIsPremiumFeatureReaction,
75 this._activateLastUsedWorkspaceReaction,
76 this._openDrawerWithSettingsReaction,
77 this._cleanupInvalidServiceReferences, 111 this._cleanupInvalidServiceReferences,
78 ]); 112 ]);
113 this._premiumUserReactions = createReactions([
114 this._setActiveServiceOnWorkspaceSwitchReaction,
115 this._activateLastUsedWorkspaceReaction,
116 this._setWorkspaceBeingEditedReaction,
117 ]);
118 this._allReactions = this._freeUserReactions.concat(this._premiumUserReactions);
119
120 this._registerReactions(this._allReactions);
79 121
80 getUserWorkspacesRequest.execute(); 122 getUserWorkspacesRequest.execute();
81 this.isFeatureActive = true; 123 this.isFeatureActive = true;
82 } 124 }
83 125
84 stop() { 126 stop() {
127 super.stop();
85 debug('WorkspacesStore::stop'); 128 debug('WorkspacesStore::stop');
86 this.isFeatureActive = false; 129 this.isFeatureActive = false;
87 this.activeWorkspace = null; 130 this.activeWorkspace = null;
@@ -104,9 +147,7 @@ export default class WorkspacesStore extends FeatureStore {
104 return workspace.services.map(id => services.one(id)).filter(s => !!s); 147 return workspace.services.map(id => services.one(id)).filter(s => !!s);
105 } 148 }
106 149
107 // ========== PRIVATE ========= // 150 // ========== PRIVATE METHODS ========= //
108
109 _wasDrawerOpenBeforeSettingsRoute = null;
110 151
111 _getWorkspaceById = id => this.workspaces.find(w => w.id === id); 152 _getWorkspaceById = id => this.workspaces.find(w => w.id === id);
112 153
@@ -192,6 +233,14 @@ export default class WorkspacesStore extends FeatureStore {
192 this.actions.ui.openSettings({ path: 'workspaces' }); 233 this.actions.ui.openSettings({ path: 'workspaces' });
193 }; 234 };
194 235
236 @action reorderServicesOfActiveWorkspace = async ({ oldIndex, newIndex }) => {
237 const { activeWorkspace } = this;
238 const { services } = activeWorkspace;
239 // Move services from the old to the new position
240 services.splice(newIndex, 0, services.splice(oldIndex, 1)[0]);
241 await updateWorkspaceRequest.execute(activeWorkspace);
242 };
243
195 // Reactions 244 // Reactions
196 245
197 _setFeatureEnabledReaction = () => { 246 _setFeatureEnabledReaction = () => {
@@ -218,13 +267,15 @@ export default class WorkspacesStore extends FeatureStore {
218 _setActiveServiceOnWorkspaceSwitchReaction = () => { 267 _setActiveServiceOnWorkspaceSwitchReaction = () => {
219 if (!this.isFeatureActive) return; 268 if (!this.isFeatureActive) return;
220 if (this.activeWorkspace) { 269 if (this.activeWorkspace) {
221 const services = this.stores.services.allDisplayed; 270 const activeService = this.stores.services.active;
222 const activeService = services.find(s => s.isActive);
223 const workspaceServices = this.getWorkspaceServices(this.activeWorkspace); 271 const workspaceServices = this.getWorkspaceServices(this.activeWorkspace);
224 if (workspaceServices.length <= 0) return; 272 if (workspaceServices.length <= 0) return;
225 const isActiveServiceInWorkspace = workspaceServices.includes(activeService); 273 const isActiveServiceInWorkspace = workspaceServices.includes(activeService);
226 if (!isActiveServiceInWorkspace) { 274 if (!isActiveServiceInWorkspace) {
227 this.actions.service.setActive({ serviceId: workspaceServices[0].id }); 275 this.actions.service.setActive({
276 serviceId: workspaceServices[0].id,
277 keepActiveRoute: true,
278 });
228 } 279 }
229 } 280 }
230 }; 281 };
@@ -273,4 +324,14 @@ export default class WorkspacesStore extends FeatureStore {
273 getUserWorkspacesRequest.execute(); 324 getUserWorkspacesRequest.execute();
274 } 325 }
275 }; 326 };
327
328 _stopPremiumActionsAndReactions = () => {
329 if (!this.isUserAllowedToUseFeature) {
330 this._stopActions(this._premiumUserActions);
331 this._stopReactions(this._premiumUserReactions);
332 } else {
333 this._startActions(this._premiumUserActions);
334 this._startReactions(this._premiumUserReactions);
335 }
336 }
276} 337}