diff options
Diffstat (limited to 'src/features')
34 files changed, 2774 insertions, 18 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | import { createActionsFromDefinitions } from '../../actions/lib/actions'; | ||
3 | |||
4 | export const announcementActions = createActionsFromDefinitions({ | ||
5 | show: { | ||
6 | targetVersion: PropTypes.string, | ||
7 | }, | ||
8 | }, PropTypes.checkPropTypes); | ||
9 | |||
10 | export 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 @@ | |||
1 | import { remote } from 'electron'; | ||
2 | import Request from '../../stores/lib/Request'; | ||
3 | import { API, API_VERSION } from '../../environment'; | ||
4 | |||
5 | const debug = require('debug')('Franz:feature:announcements:api'); | ||
6 | |||
7 | export 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 | |||
31 | export const getCurrentVersionRequest = new Request(announcementsApi, 'getCurrentVersion'); | ||
32 | export const getChangelogRequest = new Request(announcementsApi, 'getChangelog'); | ||
33 | export 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..dfce6cdd5 --- /dev/null +++ b/src/features/announcements/components/AnnouncementScreen.js | |||
@@ -0,0 +1,286 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import marked from 'marked'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import { inject, observer } from 'mobx-react'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | import injectSheet from 'react-jss'; | ||
7 | import { Button } from '@meetfranz/forms'; | ||
8 | |||
9 | import { announcementsStore } from '../index'; | ||
10 | import UIStore from '../../../stores/UIStore'; | ||
11 | import { gaEvent } from '../../../lib/analytics'; | ||
12 | |||
13 | const renderer = new marked.Renderer(); | ||
14 | |||
15 | renderer.link = (href, title, text) => `<a target="_blank" href="${href}" title="${title}">${text}</a>`; | ||
16 | |||
17 | const markedOptions = { sanitize: true, renderer }; | ||
18 | |||
19 | const messages = defineMessages({ | ||
20 | headline: { | ||
21 | id: 'feature.announcements.changelog.headline', | ||
22 | defaultMessage: '!!!Changes in Franz {version}', | ||
23 | }, | ||
24 | }); | ||
25 | |||
26 | const smallScreen = '1000px'; | ||
27 | |||
28 | const 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 | ||
190 | class 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.currentVersion, | ||
272 | })} | ||
273 | </h1> | ||
274 | <div | ||
275 | dangerouslySetInnerHTML={{ | ||
276 | __html: marked(changelog, markedOptions), | ||
277 | }} | ||
278 | /> | ||
279 | </div> | ||
280 | )} | ||
281 | </div> | ||
282 | ); | ||
283 | } | ||
284 | } | ||
285 | |||
286 | export default AnnouncementScreen; | ||
diff --git a/src/features/announcements/index.js b/src/features/announcements/index.js new file mode 100644 index 000000000..4658b976f --- /dev/null +++ b/src/features/announcements/index.js | |||
@@ -0,0 +1,32 @@ | |||
1 | import { reaction } from 'mobx'; | ||
2 | import { AnnouncementsStore } from './store'; | ||
3 | |||
4 | const debug = require('debug')('Franz:feature:announcements'); | ||
5 | |||
6 | export const GA_CATEGORY_ANNOUNCEMENTS = 'Announcements'; | ||
7 | |||
8 | export const announcementsStore = new AnnouncementsStore(); | ||
9 | |||
10 | export default function initAnnouncements(stores, actions) { | ||
11 | // const { features } = stores; | ||
12 | |||
13 | // Toggle workspace feature | ||
14 | reaction( | ||
15 | () => ( | ||
16 | true | ||
17 | // features.features.isAnnouncementsEnabled | ||
18 | ), | ||
19 | (isEnabled) => { | ||
20 | if (isEnabled) { | ||
21 | debug('Initializing `announcements` feature'); | ||
22 | announcementsStore.start(stores, actions); | ||
23 | } else if (announcementsStore.isFeatureActive) { | ||
24 | debug('Disabling `announcements` feature'); | ||
25 | announcementsStore.stop(); | ||
26 | } | ||
27 | }, | ||
28 | { | ||
29 | fireImmediately: true, | ||
30 | }, | ||
31 | ); | ||
32 | } | ||
diff --git a/src/features/announcements/store.js b/src/features/announcements/store.js new file mode 100644 index 000000000..7ecc0e346 --- /dev/null +++ b/src/features/announcements/store.js | |||
@@ -0,0 +1,144 @@ | |||
1 | import { | ||
2 | action, | ||
3 | computed, | ||
4 | observable, | ||
5 | reaction, | ||
6 | } from 'mobx'; | ||
7 | import semver from 'semver'; | ||
8 | import localStorage from 'mobx-localstorage'; | ||
9 | |||
10 | import { FeatureStore } from '../utils/FeatureStore'; | ||
11 | import { GA_CATEGORY_ANNOUNCEMENTS } from '.'; | ||
12 | import { getAnnouncementRequest, getChangelogRequest, getCurrentVersionRequest } from './api'; | ||
13 | import { announcementActions } from './actions'; | ||
14 | import { createActionBindings } from '../utils/ActionBinding'; | ||
15 | import { createReactions } from '../../stores/lib/Reaction'; | ||
16 | import { gaEvent } from '../../lib/analytics'; | ||
17 | |||
18 | const LOCAL_STORAGE_KEY = 'announcements'; | ||
19 | |||
20 | const debug = require('debug')('Franz:feature:announcements:store'); | ||
21 | |||
22 | export class AnnouncementsStore extends FeatureStore { | ||
23 | @observable targetVersion = null; | ||
24 | |||
25 | @observable isAnnouncementVisible = false; | ||
26 | |||
27 | @observable isFeatureActive = false; | ||
28 | |||
29 | @computed get changelog() { | ||
30 | return getChangelogRequest.result; | ||
31 | } | ||
32 | |||
33 | @computed get announcement() { | ||
34 | return getAnnouncementRequest.result; | ||
35 | } | ||
36 | |||
37 | @computed get areNewsAvailable() { | ||
38 | const isChangelogAvailable = getChangelogRequest.wasExecuted && !!this.changelog; | ||
39 | const isAnnouncementAvailable = getAnnouncementRequest.wasExecuted && !!this.announcement; | ||
40 | return isChangelogAvailable || isAnnouncementAvailable; | ||
41 | } | ||
42 | |||
43 | @computed get settings() { | ||
44 | return localStorage.getItem(LOCAL_STORAGE_KEY) || {}; | ||
45 | } | ||
46 | |||
47 | @computed get lastSeenAnnouncementVersion() { | ||
48 | return this.settings.lastSeenAnnouncementVersion || null; | ||
49 | } | ||
50 | |||
51 | @computed get currentVersion() { | ||
52 | return getCurrentVersionRequest.result; | ||
53 | } | ||
54 | |||
55 | @computed get isNewUser() { | ||
56 | return this.stores.settings.stats.appStarts <= 1; | ||
57 | } | ||
58 | |||
59 | async start(stores, actions) { | ||
60 | debug('AnnouncementsStore::start'); | ||
61 | this.stores = stores; | ||
62 | this.actions = actions; | ||
63 | getCurrentVersionRequest.execute(); | ||
64 | |||
65 | this._registerActions(createActionBindings([ | ||
66 | [announcementActions.show, this._showAnnouncement], | ||
67 | ])); | ||
68 | |||
69 | this._reactions = createReactions([ | ||
70 | this._fetchAnnouncements, | ||
71 | this._showAnnouncementToUsersWhoUpdatedApp, | ||
72 | ]); | ||
73 | this._registerReactions(this._reactions); | ||
74 | this.isFeatureActive = true; | ||
75 | } | ||
76 | |||
77 | stop() { | ||
78 | super.stop(); | ||
79 | debug('AnnouncementsStore::stop'); | ||
80 | this.isFeatureActive = false; | ||
81 | this.isAnnouncementVisible = false; | ||
82 | } | ||
83 | |||
84 | // ======= HELPERS ======= // | ||
85 | |||
86 | _updateSettings = (changes) => { | ||
87 | localStorage.setItem(LOCAL_STORAGE_KEY, { | ||
88 | ...this.settings, | ||
89 | ...changes, | ||
90 | }); | ||
91 | }; | ||
92 | |||
93 | // ======= ACTIONS ======= // | ||
94 | |||
95 | @action _showAnnouncement = ({ targetVersion } = {}) => { | ||
96 | if (!this.areNewsAvailable) return; | ||
97 | this.targetVersion = targetVersion || this.currentVersion; | ||
98 | this.isAnnouncementVisible = true; | ||
99 | this.actions.service.blurActive(); | ||
100 | this._updateSettings({ | ||
101 | lastSeenAnnouncementVersion: this.currentVersion, | ||
102 | }); | ||
103 | const dispose = reaction( | ||
104 | () => this.stores.services.active, | ||
105 | () => { | ||
106 | this._hideAnnouncement(); | ||
107 | dispose(); | ||
108 | }, | ||
109 | ); | ||
110 | |||
111 | gaEvent(GA_CATEGORY_ANNOUNCEMENTS, 'show'); | ||
112 | }; | ||
113 | |||
114 | @action _hideAnnouncement() { | ||
115 | this.isAnnouncementVisible = false; | ||
116 | } | ||
117 | |||
118 | // ======= REACTIONS ======== | ||
119 | |||
120 | _showAnnouncementToUsersWhoUpdatedApp = () => { | ||
121 | const { announcement, isNewUser } = this; | ||
122 | // Check if there is an announcement and on't show announcements to new users | ||
123 | if (!announcement || isNewUser) return; | ||
124 | |||
125 | // Check if the user has already used current version (= has seen the announcement) | ||
126 | const { currentVersion, lastSeenAnnouncementVersion } = this; | ||
127 | if (semver.gt(currentVersion, lastSeenAnnouncementVersion || '0.0.0')) { | ||
128 | debug(`${currentVersion} < ${lastSeenAnnouncementVersion}: announcement is shown`); | ||
129 | this._showAnnouncement(); | ||
130 | } | ||
131 | }; | ||
132 | |||
133 | _fetchAnnouncements = () => { | ||
134 | const targetVersion = this.targetVersion || this.currentVersion; | ||
135 | if (!targetVersion) return; | ||
136 | getChangelogRequest.execute(targetVersion); | ||
137 | // We only fetch announcements for current / older versions | ||
138 | if (targetVersion <= this.currentVersion) { | ||
139 | getAnnouncementRequest.execute(targetVersion); | ||
140 | } else { | ||
141 | getAnnouncementRequest.reset(); | ||
142 | } | ||
143 | } | ||
144 | } | ||
diff --git a/src/features/basicAuth/Component.js b/src/features/basicAuth/Component.js index 13395fb40..a8252acb7 100644 --- a/src/features/basicAuth/Component.js +++ b/src/features/basicAuth/Component.js | |||
@@ -62,6 +62,7 @@ export default @injectSheet(styles) @observer class BasicAuthModal extends Compo | |||
62 | isOpen={isModalVisible} | 62 | isOpen={isModalVisible} |
63 | className={classes.modal} | 63 | className={classes.modal} |
64 | close={this.cancel.bind(this)} | 64 | close={this.cancel.bind(this)} |
65 | showClose={false} | ||
65 | > | 66 | > |
66 | <h1>Sign in</h1> | 67 | <h1>Sign in</h1> |
67 | <p> | 68 | <p> |
diff --git a/src/features/basicAuth/index.js b/src/features/basicAuth/index.js index 03269582c..89607824b 100644 --- a/src/features/basicAuth/index.js +++ b/src/features/basicAuth/index.js | |||
@@ -6,7 +6,7 @@ import BasicAuthComponent from './Component'; | |||
6 | const debug = require('debug')('Franz:feature:basicAuth'); | 6 | const debug = require('debug')('Franz:feature:basicAuth'); |
7 | 7 | ||
8 | const defaultState = { | 8 | const defaultState = { |
9 | isModalVisible: false, | 9 | isModalVisible: true, |
10 | service: null, | 10 | service: null, |
11 | authInfo: null, | 11 | authInfo: null, |
12 | }; | 12 | }; |
@@ -15,7 +15,6 @@ export const state = observable(defaultState); | |||
15 | 15 | ||
16 | export function resetState() { | 16 | export function resetState() { |
17 | Object.assign(state, defaultState); | 17 | Object.assign(state, defaultState); |
18 | console.log('reset state', state); | ||
19 | } | 18 | } |
20 | 19 | ||
21 | export default function initialize() { | 20 | export default function initialize() { |
@@ -31,15 +30,6 @@ export default function initialize() { | |||
31 | state.authInfo = data.authInfo; | 30 | state.authInfo = data.authInfo; |
32 | state.isModalVisible = true; | 31 | state.isModalVisible = true; |
33 | }); | 32 | }); |
34 | |||
35 | // autorun(() => { | ||
36 | // // if (state.serviceId) { | ||
37 | // // const service = stores.services.one(state.serviceId); | ||
38 | // // if (service) { | ||
39 | // // state.service = service; | ||
40 | // // } | ||
41 | // // } | ||
42 | // }); | ||
43 | } | 33 | } |
44 | 34 | ||
45 | export function mainIpcHandler(mainWindow, authInfo) { | 35 | export function mainIpcHandler(mainWindow, authInfo) { |
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/delayApp/index.js b/src/features/delayApp/index.js index 28aa50eb2..67f0fc5e6 100644 --- a/src/features/delayApp/index.js +++ b/src/features/delayApp/index.js | |||
@@ -3,7 +3,7 @@ import moment from 'moment'; | |||
3 | import DelayAppComponent from './Component'; | 3 | import DelayAppComponent from './Component'; |
4 | 4 | ||
5 | import { DEFAULT_FEATURES_CONFIG } from '../../config'; | 5 | import { DEFAULT_FEATURES_CONFIG } from '../../config'; |
6 | import { gaEvent } from '../../lib/analytics'; | 6 | import { gaEvent, gaPage } from '../../lib/analytics'; |
7 | 7 | ||
8 | const debug = require('debug')('Franz:feature:delayApp'); | 8 | const debug = require('debug')('Franz:feature:delayApp'); |
9 | 9 | ||
@@ -28,8 +28,12 @@ export default function init(stores) { | |||
28 | let shownAfterLaunch = false; | 28 | let shownAfterLaunch = false; |
29 | let timeLastDelay = moment(); | 29 | let timeLastDelay = moment(); |
30 | 30 | ||
31 | window.franz.features.delayApp = { | ||
32 | state, | ||
33 | }; | ||
34 | |||
31 | reaction( | 35 | reaction( |
32 | () => stores.features.features.needToWaitToProceed && !stores.user.data.isPremium, | 36 | () => stores.user.isLoggedIn && stores.features.features.needToWaitToProceed && !stores.user.data.isPremium, |
33 | (isEnabled) => { | 37 | (isEnabled) => { |
34 | if (isEnabled) { | 38 | if (isEnabled) { |
35 | debug('Enabling `delayApp` feature'); | 39 | debug('Enabling `delayApp` feature'); |
@@ -50,7 +54,8 @@ export default function init(stores) { | |||
50 | debug(`App will be delayed for ${config.delayDuration / 1000}s`); | 54 | debug(`App will be delayed for ${config.delayDuration / 1000}s`); |
51 | 55 | ||
52 | setVisibility(true); | 56 | setVisibility(true); |
53 | gaEvent('delayApp', 'show', 'Delay App Feature'); | 57 | gaPage('/delayApp'); |
58 | gaEvent('DelayApp', 'show', 'Delay App Feature'); | ||
54 | 59 | ||
55 | timeLastDelay = moment(); | 60 | timeLastDelay = moment(); |
56 | shownAfterLaunch = true; | 61 | shownAfterLaunch = true; |
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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | import { createActionsFromDefinitions } from '../../actions/lib/actions'; | ||
3 | |||
4 | export const settingsWSActions = createActionsFromDefinitions({ | ||
5 | greet: { | ||
6 | name: PropTypes.string.isRequired, | ||
7 | }, | ||
8 | }, PropTypes.checkPropTypes); | ||
9 | |||
10 | export 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 @@ | |||
1 | import { reaction } from 'mobx'; | ||
2 | import { SettingsWSStore } from './store'; | ||
3 | |||
4 | const debug = require('debug')('Franz:feature:settingsWS'); | ||
5 | |||
6 | export const settingsStore = new SettingsWSStore(); | ||
7 | |||
8 | export 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 @@ | |||
1 | import { observable } from 'mobx'; | ||
2 | |||
3 | const defaultState = { | ||
4 | isFeatureActive: false, | ||
5 | }; | ||
6 | |||
7 | export const settingsWSState = observable(defaultState); | ||
8 | |||
9 | export function resetState() { | ||
10 | Object.assign(settingsWSState, defaultState); | ||
11 | } | ||
12 | |||
13 | export 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 @@ | |||
1 | import { observable } from 'mobx'; | ||
2 | import WebSocket from 'ws'; | ||
3 | import ms from 'ms'; | ||
4 | |||
5 | import { FeatureStore } from '../utils/FeatureStore'; | ||
6 | import { createReactions } from '../../stores/lib/Reaction'; | ||
7 | import { WS_API } from '../../environment'; | ||
8 | |||
9 | const debug = require('debug')('Franz:feature:settingsWS:store'); | ||
10 | |||
11 | export 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 | |||
130 | export default SettingsWSStore; | ||
diff --git a/src/features/shareFranz/Component.js b/src/features/shareFranz/Component.js new file mode 100644 index 000000000..8d1d595c5 --- /dev/null +++ b/src/features/shareFranz/Component.js | |||
@@ -0,0 +1,166 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, inject } from 'mobx-react'; | ||
4 | import injectSheet from 'react-jss'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | import { Button } from '@meetfranz/forms'; | ||
7 | import { H1, Icon } from '@meetfranz/ui'; | ||
8 | |||
9 | import Modal from '../../components/ui/Modal'; | ||
10 | import { state } from '.'; | ||
11 | import { gaEvent } from '../../lib/analytics'; | ||
12 | import ServicesStore from '../../stores/ServicesStore'; | ||
13 | |||
14 | const messages = defineMessages({ | ||
15 | headline: { | ||
16 | id: 'feature.shareFranz.headline', | ||
17 | defaultMessage: '!!!Franz is better together!', | ||
18 | }, | ||
19 | text: { | ||
20 | id: 'feature.shareFranz.text', | ||
21 | defaultMessage: '!!!Tell your friends and colleagues how awesome Franz is and help us to spread the word.', | ||
22 | }, | ||
23 | actionsEmail: { | ||
24 | id: 'feature.shareFranz.action.email', | ||
25 | defaultMessage: '!!!Share as email', | ||
26 | }, | ||
27 | actionsFacebook: { | ||
28 | id: 'feature.shareFranz.action.facebook', | ||
29 | defaultMessage: '!!!Share on Facebook', | ||
30 | }, | ||
31 | actionsTwitter: { | ||
32 | id: 'feature.shareFranz.action.twitter', | ||
33 | defaultMessage: '!!!Share on Twitter', | ||
34 | }, | ||
35 | shareTextEmail: { | ||
36 | id: 'feature.shareFranz.shareText.email', | ||
37 | defaultMessage: '!!! I\'ve added {count} services to Franz! Get the free app for WhatsApp, Messenger, Slack, Skype and co at www.meetfranz.com', | ||
38 | }, | ||
39 | shareTextTwitter: { | ||
40 | id: 'feature.shareFranz.shareText.twitter', | ||
41 | defaultMessage: '!!! I\'ve added {count} services to Franz! Get the free app for WhatsApp, Messenger, Slack, Skype and co at www.meetfranz.com /cc @FranzMessenger', | ||
42 | }, | ||
43 | }); | ||
44 | |||
45 | const styles = theme => ({ | ||
46 | modal: { | ||
47 | width: '80%', | ||
48 | maxWidth: 600, | ||
49 | background: theme.styleTypes.primary.accent, | ||
50 | textAlign: 'center', | ||
51 | color: theme.styleTypes.primary.contrast, | ||
52 | }, | ||
53 | heartContainer: { | ||
54 | display: 'flex', | ||
55 | justifyContent: 'center', | ||
56 | borderRadius: '100%', | ||
57 | background: theme.brandDanger, | ||
58 | padding: 20, | ||
59 | width: 100, | ||
60 | height: 100, | ||
61 | margin: [-70, 'auto', 30], | ||
62 | }, | ||
63 | heart: { | ||
64 | fill: theme.styleTypes.primary.contrast, | ||
65 | }, | ||
66 | headline: { | ||
67 | textAlign: 'center', | ||
68 | fontSize: 40, | ||
69 | marginBottom: 20, | ||
70 | }, | ||
71 | actions: { | ||
72 | display: 'flex', | ||
73 | justifyContent: 'space-between', | ||
74 | marginTop: 30, | ||
75 | }, | ||
76 | cta: { | ||
77 | background: theme.styleTypes.primary.contrast, | ||
78 | color: theme.styleTypes.primary.accent, | ||
79 | |||
80 | '& svg': { | ||
81 | fill: theme.styleTypes.primary.accent, | ||
82 | }, | ||
83 | }, | ||
84 | }); | ||
85 | |||
86 | export default @injectSheet(styles) @inject('stores') @observer class ShareFranzModal extends Component { | ||
87 | static propTypes = { | ||
88 | classes: PropTypes.object.isRequired, | ||
89 | }; | ||
90 | |||
91 | static contextTypes = { | ||
92 | intl: intlShape, | ||
93 | }; | ||
94 | |||
95 | close() { | ||
96 | state.isModalVisible = false; | ||
97 | } | ||
98 | |||
99 | render() { | ||
100 | const { isModalVisible } = state; | ||
101 | |||
102 | const { | ||
103 | classes, | ||
104 | stores, | ||
105 | } = this.props; | ||
106 | |||
107 | const serviceCount = stores.services.all.length; | ||
108 | |||
109 | const { intl } = this.context; | ||
110 | |||
111 | return ( | ||
112 | <Modal | ||
113 | isOpen={isModalVisible} | ||
114 | className={classes.modal} | ||
115 | shouldCloseOnOverlayClick | ||
116 | close={this.close.bind(this)} | ||
117 | > | ||
118 | <div className={classes.heartContainer}> | ||
119 | <Icon icon="mdiHeart" className={classes.heart} size={4} /> | ||
120 | </div> | ||
121 | <H1 className={classes.headline}> | ||
122 | {intl.formatMessage(messages.headline)} | ||
123 | </H1> | ||
124 | <p>{intl.formatMessage(messages.text)}</p> | ||
125 | <div className={classes.actions}> | ||
126 | <Button | ||
127 | label={intl.formatMessage(messages.actionsEmail)} | ||
128 | className={classes.cta} | ||
129 | icon="mdiEmail" | ||
130 | href={`mailto:?subject=Meet the cool app Franz&body=${intl.formatMessage(messages.shareTextEmail, { count: serviceCount })}}`} | ||
131 | target="_blank" | ||
132 | onClick={() => { | ||
133 | gaEvent('Share Franz', 'share', 'Share via email'); | ||
134 | }} | ||
135 | /> | ||
136 | <Button | ||
137 | label={intl.formatMessage(messages.actionsFacebook)} | ||
138 | className={classes.cta} | ||
139 | icon="mdiFacebookBox" | ||
140 | href="https://www.facebook.com/sharer/sharer.php?u=https://www.meetfranz.com?utm_source=facebook&utm_medium=referral&utm_campaign=share-button" | ||
141 | target="_blank" | ||
142 | onClick={() => { | ||
143 | gaEvent('Share Franz', 'share', 'Share via Facebook'); | ||
144 | }} | ||
145 | /> | ||
146 | <Button | ||
147 | label={intl.formatMessage(messages.actionsTwitter)} | ||
148 | className={classes.cta} | ||
149 | icon="mdiTwitter" | ||
150 | href={`http://twitter.com/intent/tweet?status=${intl.formatMessage(messages.shareTextTwitter, { count: serviceCount })}`} | ||
151 | target="_blank" | ||
152 | onClick={() => { | ||
153 | gaEvent('Share Franz', 'share', 'Share via Twitter'); | ||
154 | }} | ||
155 | /> | ||
156 | </div> | ||
157 | </Modal> | ||
158 | ); | ||
159 | } | ||
160 | } | ||
161 | |||
162 | ShareFranzModal.wrappedComponent.propTypes = { | ||
163 | stores: PropTypes.shape({ | ||
164 | services: PropTypes.instanceOf(ServicesStore).isRequired, | ||
165 | }).isRequired, | ||
166 | }; | ||
diff --git a/src/features/shareFranz/index.js b/src/features/shareFranz/index.js new file mode 100644 index 000000000..87deacef4 --- /dev/null +++ b/src/features/shareFranz/index.js | |||
@@ -0,0 +1,52 @@ | |||
1 | import { observable, reaction } from 'mobx'; | ||
2 | import ms from 'ms'; | ||
3 | |||
4 | import { state as delayAppState } from '../delayApp'; | ||
5 | import { gaEvent, gaPage } from '../../lib/analytics'; | ||
6 | |||
7 | export { default as Component } from './Component'; | ||
8 | |||
9 | const debug = require('debug')('Franz:feature:shareFranz'); | ||
10 | |||
11 | const defaultState = { | ||
12 | isModalVisible: false, | ||
13 | lastShown: null, | ||
14 | }; | ||
15 | |||
16 | export const state = observable(defaultState); | ||
17 | |||
18 | export default function initialize(stores) { | ||
19 | debug('Initialize shareFranz feature'); | ||
20 | |||
21 | window.franz.features.shareFranz = { | ||
22 | state, | ||
23 | }; | ||
24 | |||
25 | function showModal() { | ||
26 | debug('Showing share window'); | ||
27 | |||
28 | state.isModalVisible = true; | ||
29 | |||
30 | gaEvent('Share Franz', 'show'); | ||
31 | gaPage('/share-modal'); | ||
32 | } | ||
33 | |||
34 | reaction( | ||
35 | () => stores.user.isLoggedIn, | ||
36 | () => { | ||
37 | setTimeout(() => { | ||
38 | if (stores.settings.stats.appStarts % 50 === 0) { | ||
39 | if (delayAppState.isDelayAppScreenVisible) { | ||
40 | debug('Delaying share modal by 5 minutes'); | ||
41 | setTimeout(() => showModal(), ms('5m')); | ||
42 | } else { | ||
43 | showModal(); | ||
44 | } | ||
45 | } | ||
46 | }, ms('2s')); | ||
47 | }, | ||
48 | { | ||
49 | fireImmediately: true, | ||
50 | }, | ||
51 | ); | ||
52 | } | ||
diff --git a/src/features/spellchecker/index.js b/src/features/spellchecker/index.js index 63506103c..79a2172b4 100644 --- a/src/features/spellchecker/index.js +++ b/src/features/spellchecker/index.js | |||
@@ -5,7 +5,7 @@ import { DEFAULT_FEATURES_CONFIG } from '../../config'; | |||
5 | const debug = require('debug')('Franz:feature:spellchecker'); | 5 | const debug = require('debug')('Franz:feature:spellchecker'); |
6 | 6 | ||
7 | export const config = observable({ | 7 | export const config = observable({ |
8 | isPremiumFeature: DEFAULT_FEATURES_CONFIG.isSpellcheckerPremiumFeature, | 8 | isPremium: DEFAULT_FEATURES_CONFIG.isSpellcheckerPremiumFeature, |
9 | }); | 9 | }); |
10 | 10 | ||
11 | export default function init(stores) { | 11 | export default function init(stores) { |
@@ -14,9 +14,9 @@ export default function init(stores) { | |||
14 | autorun(() => { | 14 | autorun(() => { |
15 | const { isSpellcheckerPremiumFeature } = stores.features.features; | 15 | const { isSpellcheckerPremiumFeature } = stores.features.features; |
16 | 16 | ||
17 | config.isPremiumFeature = isSpellcheckerPremiumFeature !== undefined ? isSpellcheckerPremiumFeature : DEFAULT_FEATURES_CONFIG.isSpellcheckerPremiumFeature; | 17 | config.isPremium = isSpellcheckerPremiumFeature !== undefined ? isSpellcheckerPremiumFeature : DEFAULT_FEATURES_CONFIG.isSpellcheckerPremiumFeature; |
18 | 18 | ||
19 | if (!stores.user.data.isPremium && config.isPremiumFeature && stores.settings.app.enableSpellchecking) { | 19 | if (!stores.user.data.isPremium && config.isPremium && stores.settings.app.enableSpellchecking) { |
20 | debug('Override settings.spellcheckerEnabled flag to false'); | 20 | debug('Override settings.spellcheckerEnabled flag to false'); |
21 | 21 | ||
22 | Object.assign(stores.settings.app, { | 22 | Object.assign(stores.settings.app, { |
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 @@ | |||
1 | export 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 | |||
27 | export 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 new file mode 100644 index 000000000..0bc10e176 --- /dev/null +++ b/src/features/utils/FeatureStore.js | |||
@@ -0,0 +1,40 @@ | |||
1 | export class FeatureStore { | ||
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 | } | ||
17 | |||
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 | } | ||
32 | |||
33 | _startReactions(reactions = this._reactions) { | ||
34 | reactions.forEach(r => r.start()); | ||
35 | } | ||
36 | |||
37 | _stopReactions(reactions = this._reactions) { | ||
38 | reactions.forEach(r => r.stop()); | ||
39 | } | ||
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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | import { observable } from 'mobx'; | ||
3 | import { FeatureStore } from './FeatureStore'; | ||
4 | import { createActionsFromDefinitions } from '../../actions/lib/actions'; | ||
5 | import { createActionBindings } from './ActionBinding'; | ||
6 | import { createReactions } from '../../stores/lib/Reaction'; | ||
7 | |||
8 | const actions = createActionsFromDefinitions({ | ||
9 | countUp: {}, | ||
10 | }, PropTypes.checkPropTypes); | ||
11 | |||
12 | class 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 | |||
35 | describe('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/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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | import Workspace from './models/Workspace'; | ||
3 | import { createActionsFromDefinitions } from '../../actions/lib/actions'; | ||
4 | |||
5 | export const workspaceActions = 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 | toggleWorkspaceDrawer: {}, | ||
23 | openWorkspaceSettings: {}, | ||
24 | }, PropTypes.checkPropTypes); | ||
25 | |||
26 | 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 @@ | |||
1 | import { pick } from 'lodash'; | ||
2 | import { sendAuthRequest } from '../../api/utils/auth'; | ||
3 | import { API, API_VERSION } from '../../environment'; | ||
4 | import Request from '../../stores/lib/Request'; | ||
5 | import Workspace from './models/Workspace'; | ||
6 | |||
7 | const debug = require('debug')('Franz:feature:workspaces:api'); | ||
8 | |||
9 | export const workspaceApi = { | ||
10 | getUserWorkspaces: async () => { | ||
11 | const url = `${API}/${API_VERSION}/workspace`; | ||
12 | debug('getUserWorkspaces GET', url); | ||
13 | const result = await sendAuthRequest(url, { method: 'GET' }); | ||
14 | debug('getUserWorkspaces RESULT', result); | ||
15 | if (!result.ok) throw result; | ||
16 | const workspaces = await result.json(); | ||
17 | return workspaces.map(data => new Workspace(data)); | ||
18 | }, | ||
19 | |||
20 | createWorkspace: async (name) => { | ||
21 | const url = `${API}/${API_VERSION}/workspace`; | ||
22 | const options = { | ||
23 | method: 'POST', | ||
24 | body: JSON.stringify({ name }), | ||
25 | }; | ||
26 | debug('createWorkspace POST', url, options); | ||
27 | const result = await sendAuthRequest(url, options); | ||
28 | debug('createWorkspace RESULT', result); | ||
29 | if (!result.ok) throw result; | ||
30 | return new Workspace(await result.json()); | ||
31 | }, | ||
32 | |||
33 | deleteWorkspace: async (workspace) => { | ||
34 | const url = `${API}/${API_VERSION}/workspace/${workspace.id}`; | ||
35 | debug('deleteWorkspace DELETE', url); | ||
36 | const result = await sendAuthRequest(url, { method: 'DELETE' }); | ||
37 | debug('deleteWorkspace RESULT', result); | ||
38 | if (!result.ok) throw result; | ||
39 | return true; | ||
40 | }, | ||
41 | |||
42 | updateWorkspace: async (workspace) => { | ||
43 | const url = `${API}/${API_VERSION}/workspace/${workspace.id}`; | ||
44 | const options = { | ||
45 | method: 'PUT', | ||
46 | body: JSON.stringify(pick(workspace, ['name', 'services'])), | ||
47 | }; | ||
48 | debug('updateWorkspace UPDATE', url, options); | ||
49 | const result = await sendAuthRequest(url, options); | ||
50 | debug('updateWorkspace RESULT', result); | ||
51 | if (!result.ok) throw result; | ||
52 | return new Workspace(await result.json()); | ||
53 | }, | ||
54 | }; | ||
55 | |||
56 | export const getUserWorkspacesRequest = new Request(workspaceApi, 'getUserWorkspaces'); | ||
57 | export const createWorkspaceRequest = new Request(workspaceApi, 'createWorkspace'); | ||
58 | export const deleteWorkspaceRequest = new Request(workspaceApi, 'deleteWorkspace'); | ||
59 | export const updateWorkspaceRequest = new Request(workspaceApi, 'updateWorkspace'); | ||
60 | |||
61 | export const resetApiRequests = () => { | ||
62 | getUserWorkspacesRequest.reset(); | ||
63 | createWorkspaceRequest.reset(); | ||
64 | deleteWorkspaceRequest.reset(); | ||
65 | updateWorkspaceRequest.reset(); | ||
66 | }; | ||
diff --git a/src/features/workspaces/components/CreateWorkspaceForm.js b/src/features/workspaces/components/CreateWorkspaceForm.js new file mode 100644 index 000000000..cddbb2b04 --- /dev/null +++ b/src/features/workspaces/components/CreateWorkspaceForm.js | |||
@@ -0,0 +1,100 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import { Input, Button } from '@meetfranz/forms'; | ||
6 | import injectSheet from 'react-jss'; | ||
7 | import Form from '../../../lib/Form'; | ||
8 | import { required } from '../../../helpers/validation-helpers'; | ||
9 | import { gaEvent } from '../../../lib/analytics'; | ||
10 | import { GA_CATEGORY_WORKSPACES, workspaceStore } from '../index'; | ||
11 | |||
12 | const messages = defineMessages({ | ||
13 | submitButton: { | ||
14 | id: 'settings.workspace.add.form.submitButton', | ||
15 | defaultMessage: '!!!Create workspace', | ||
16 | }, | ||
17 | name: { | ||
18 | id: 'settings.workspace.add.form.name', | ||
19 | defaultMessage: '!!!Name', | ||
20 | }, | ||
21 | }); | ||
22 | |||
23 | const styles = () => ({ | ||
24 | form: { | ||
25 | display: 'flex', | ||
26 | }, | ||
27 | input: { | ||
28 | flexGrow: 1, | ||
29 | marginRight: '10px', | ||
30 | }, | ||
31 | submitButton: { | ||
32 | height: 'inherit', | ||
33 | }, | ||
34 | }); | ||
35 | |||
36 | @injectSheet(styles) @observer | ||
37 | class CreateWorkspaceForm extends Component { | ||
38 | static contextTypes = { | ||
39 | intl: intlShape, | ||
40 | }; | ||
41 | |||
42 | static propTypes = { | ||
43 | classes: PropTypes.object.isRequired, | ||
44 | isSubmitting: PropTypes.bool.isRequired, | ||
45 | onSubmit: PropTypes.func.isRequired, | ||
46 | }; | ||
47 | |||
48 | form = (() => { | ||
49 | const { intl } = this.context; | ||
50 | return new Form({ | ||
51 | fields: { | ||
52 | name: { | ||
53 | label: intl.formatMessage(messages.name), | ||
54 | placeholder: intl.formatMessage(messages.name), | ||
55 | value: '', | ||
56 | validators: [required], | ||
57 | }, | ||
58 | }, | ||
59 | }); | ||
60 | })(); | ||
61 | |||
62 | submitForm() { | ||
63 | const { form } = this; | ||
64 | form.submit({ | ||
65 | onSuccess: async (f) => { | ||
66 | const { onSubmit } = this.props; | ||
67 | const values = f.values(); | ||
68 | onSubmit(values); | ||
69 | gaEvent(GA_CATEGORY_WORKSPACES, 'create', values.name); | ||
70 | }, | ||
71 | }); | ||
72 | } | ||
73 | |||
74 | render() { | ||
75 | const { intl } = this.context; | ||
76 | const { classes, isSubmitting } = this.props; | ||
77 | const { form } = this; | ||
78 | return ( | ||
79 | <div className={classes.form}> | ||
80 | <Input | ||
81 | className={classes.input} | ||
82 | {...form.$('name').bind()} | ||
83 | showLabel={false} | ||
84 | onEnterKey={this.submitForm.bind(this, form)} | ||
85 | focus={workspaceStore.isUserAllowedToUseFeature} | ||
86 | /> | ||
87 | <Button | ||
88 | className={classes.submitButton} | ||
89 | type="submit" | ||
90 | label={intl.formatMessage(messages.submitButton)} | ||
91 | onClick={this.submitForm.bind(this, form)} | ||
92 | busy={isSubmitting} | ||
93 | buttonType={isSubmitting ? 'secondary' : 'primary'} | ||
94 | /> | ||
95 | </div> | ||
96 | ); | ||
97 | } | ||
98 | } | ||
99 | |||
100 | 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..e602ebd5a --- /dev/null +++ b/src/features/workspaces/components/EditWorkspaceForm.js | |||
@@ -0,0 +1,212 @@ | |||
1 | import React, { Component, Fragment } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import { Link } from 'react-router'; | ||
6 | import { Input, Button } from '@meetfranz/forms'; | ||
7 | import injectSheet from 'react-jss'; | ||
8 | |||
9 | import Workspace from '../models/Workspace'; | ||
10 | import Service from '../../../models/Service'; | ||
11 | import Form from '../../../lib/Form'; | ||
12 | import { required } from '../../../helpers/validation-helpers'; | ||
13 | import WorkspaceServiceListItem from './WorkspaceServiceListItem'; | ||
14 | import Request from '../../../stores/lib/Request'; | ||
15 | import { gaEvent } from '../../../lib/analytics'; | ||
16 | import { GA_CATEGORY_WORKSPACES } from '../index'; | ||
17 | |||
18 | const messages = defineMessages({ | ||
19 | buttonDelete: { | ||
20 | id: 'settings.workspace.form.buttonDelete', | ||
21 | defaultMessage: '!!!Delete workspace', | ||
22 | }, | ||
23 | buttonSave: { | ||
24 | id: 'settings.workspace.form.buttonSave', | ||
25 | defaultMessage: '!!!Save workspace', | ||
26 | }, | ||
27 | name: { | ||
28 | id: 'settings.workspace.form.name', | ||
29 | defaultMessage: '!!!Name', | ||
30 | }, | ||
31 | yourWorkspaces: { | ||
32 | id: 'settings.workspace.form.yourWorkspaces', | ||
33 | defaultMessage: '!!!Your workspaces', | ||
34 | }, | ||
35 | servicesInWorkspaceHeadline: { | ||
36 | id: 'settings.workspace.form.servicesInWorkspaceHeadline', | ||
37 | defaultMessage: '!!!Services in this Workspace', | ||
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 | }, | ||
47 | }); | ||
48 | |||
49 | const styles = () => ({ | ||
50 | nameInput: { | ||
51 | height: 'auto', | ||
52 | }, | ||
53 | serviceList: { | ||
54 | height: 'auto', | ||
55 | }, | ||
56 | }); | ||
57 | |||
58 | @injectSheet(styles) @observer | ||
59 | class EditWorkspaceForm extends Component { | ||
60 | static contextTypes = { | ||
61 | intl: intlShape, | ||
62 | }; | ||
63 | |||
64 | static propTypes = { | ||
65 | classes: PropTypes.object.isRequired, | ||
66 | onDelete: PropTypes.func.isRequired, | ||
67 | onSave: PropTypes.func.isRequired, | ||
68 | services: PropTypes.arrayOf(PropTypes.instanceOf(Service)).isRequired, | ||
69 | workspace: PropTypes.instanceOf(Workspace).isRequired, | ||
70 | updateWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, | ||
71 | deleteWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, | ||
72 | }; | ||
73 | |||
74 | form = this.prepareWorkspaceForm(this.props.workspace); | ||
75 | |||
76 | componentWillReceiveProps(nextProps) { | ||
77 | const { workspace } = this.props; | ||
78 | if (workspace.id !== nextProps.workspace.id) { | ||
79 | this.form = this.prepareWorkspaceForm(nextProps.workspace); | ||
80 | } | ||
81 | } | ||
82 | |||
83 | prepareWorkspaceForm(workspace) { | ||
84 | const { intl } = this.context; | ||
85 | return new Form({ | ||
86 | fields: { | ||
87 | name: { | ||
88 | label: intl.formatMessage(messages.name), | ||
89 | placeholder: intl.formatMessage(messages.name), | ||
90 | value: workspace.name, | ||
91 | validators: [required], | ||
92 | }, | ||
93 | services: { | ||
94 | value: workspace.services.slice(), | ||
95 | }, | ||
96 | }, | ||
97 | }); | ||
98 | } | ||
99 | |||
100 | save(form) { | ||
101 | form.submit({ | ||
102 | onSuccess: async (f) => { | ||
103 | const { onSave } = this.props; | ||
104 | const values = f.values(); | ||
105 | onSave(values); | ||
106 | gaEvent(GA_CATEGORY_WORKSPACES, 'save'); | ||
107 | }, | ||
108 | onError: async () => {}, | ||
109 | }); | ||
110 | } | ||
111 | |||
112 | delete() { | ||
113 | const { onDelete } = this.props; | ||
114 | onDelete(); | ||
115 | gaEvent(GA_CATEGORY_WORKSPACES, 'delete'); | ||
116 | } | ||
117 | |||
118 | toggleService(service) { | ||
119 | const servicesField = this.form.$('services'); | ||
120 | const serviceIds = servicesField.value; | ||
121 | if (serviceIds.includes(service.id)) { | ||
122 | serviceIds.splice(serviceIds.indexOf(service.id), 1); | ||
123 | } else { | ||
124 | serviceIds.push(service.id); | ||
125 | } | ||
126 | servicesField.set(serviceIds); | ||
127 | } | ||
128 | |||
129 | render() { | ||
130 | const { intl } = this.context; | ||
131 | const { | ||
132 | classes, | ||
133 | workspace, | ||
134 | services, | ||
135 | deleteWorkspaceRequest, | ||
136 | updateWorkspaceRequest, | ||
137 | } = this.props; | ||
138 | const { form } = this; | ||
139 | const workspaceServices = form.$('services').value; | ||
140 | const isDeleting = deleteWorkspaceRequest.isExecuting; | ||
141 | const isSaving = updateWorkspaceRequest.isExecuting; | ||
142 | return ( | ||
143 | <div className="settings__main"> | ||
144 | <div className="settings__header"> | ||
145 | <span className="settings__header-item"> | ||
146 | <Link to="/settings/workspaces"> | ||
147 | {intl.formatMessage(messages.yourWorkspaces)} | ||
148 | </Link> | ||
149 | </span> | ||
150 | <span className="separator" /> | ||
151 | <span className="settings__header-item"> | ||
152 | {workspace.name} | ||
153 | </span> | ||
154 | </div> | ||
155 | <div className="settings__body"> | ||
156 | <div className={classes.nameInput}> | ||
157 | <Input {...form.$('name').bind()} /> | ||
158 | </div> | ||
159 | <h2>{intl.formatMessage(messages.servicesInWorkspaceHeadline)}</h2> | ||
160 | <div className={classes.serviceList}> | ||
161 | {services.length === 0 ? ( | ||
162 | <div className="align-middle settings__empty-state"> | ||
163 | {/* ===== Empty state ===== */} | ||
164 | <p className="settings__empty-text"> | ||
165 | <span className="emoji"> | ||
166 | <img src="./assets/images/emoji/sad.png" alt="" /> | ||
167 | </span> | ||
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 | )} | ||
184 | </div> | ||
185 | </div> | ||
186 | <div className="settings__controls"> | ||
187 | {/* ===== Delete Button ===== */} | ||
188 | <Button | ||
189 | label={intl.formatMessage(messages.buttonDelete)} | ||
190 | loaded={false} | ||
191 | busy={isDeleting} | ||
192 | buttonType={isDeleting ? 'secondary' : 'danger'} | ||
193 | className="settings__delete-button" | ||
194 | disabled={isDeleting} | ||
195 | onClick={this.delete.bind(this)} | ||
196 | /> | ||
197 | {/* ===== Save Button ===== */} | ||
198 | <Button | ||
199 | type="submit" | ||
200 | label={intl.formatMessage(messages.buttonSave)} | ||
201 | busy={isSaving} | ||
202 | buttonType={isSaving ? 'secondary' : 'primary'} | ||
203 | onClick={this.save.bind(this, form)} | ||
204 | disabled={isSaving} | ||
205 | /> | ||
206 | </div> | ||
207 | </div> | ||
208 | ); | ||
209 | } | ||
210 | } | ||
211 | |||
212 | 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import injectSheet from 'react-jss'; | ||
5 | import { defineMessages, FormattedHTMLMessage, intlShape } from 'react-intl'; | ||
6 | import { H1, Icon, ProBadge } from '@meetfranz/ui'; | ||
7 | import { Button } from '@meetfranz/forms/lib'; | ||
8 | import ReactTooltip from 'react-tooltip'; | ||
9 | |||
10 | import WorkspaceDrawerItem from './WorkspaceDrawerItem'; | ||
11 | import { workspaceActions } from '../actions'; | ||
12 | import { GA_CATEGORY_WORKSPACES, workspaceStore } from '../index'; | ||
13 | import { gaEvent } from '../../../lib/analytics'; | ||
14 | |||
15 | const messages = defineMessages({ | ||
16 | headline: { | ||
17 | id: 'workspaceDrawer.headline', | ||
18 | defaultMessage: '!!!Workspaces', | ||
19 | }, | ||
20 | allServices: { | ||
21 | id: 'workspaceDrawer.allServices', | ||
22 | defaultMessage: '!!!All services', | ||
23 | }, | ||
24 | workspacesSettingsTooltip: { | ||
25 | id: 'workspaceDrawer.workspacesSettingsTooltip', | ||
26 | defaultMessage: '!!!Workspaces settings', | ||
27 | }, | ||
28 | workspaceFeatureInfo: { | ||
29 | id: 'workspaceDrawer.workspaceFeatureInfo', | ||
30 | defaultMessage: '!!!Info about workspace feature', | ||
31 | }, | ||
32 | premiumCtaButtonLabel: { | ||
33 | id: 'workspaceDrawer.premiumCtaButtonLabel', | ||
34 | defaultMessage: '!!!Create your first workspace', | ||
35 | }, | ||
36 | reactivatePremiumAccount: { | ||
37 | id: 'workspaceDrawer.reactivatePremiumAccountLabel', | ||
38 | defaultMessage: '!!!Reactivate premium account', | ||
39 | }, | ||
40 | addNewWorkspaceLabel: { | ||
41 | id: 'workspaceDrawer.addNewWorkspaceLabel', | ||
42 | defaultMessage: '!!!add new workspace', | ||
43 | }, | ||
44 | premiumFeatureBadge: { | ||
45 | id: 'workspaceDrawer.proFeatureBadge', | ||
46 | defaultMessage: '!!!Premium feature', | ||
47 | }, | ||
48 | }); | ||
49 | |||
50 | const styles = theme => ({ | ||
51 | drawer: { | ||
52 | background: theme.workspaces.drawer.background, | ||
53 | width: `${theme.workspaces.drawer.width}px`, | ||
54 | }, | ||
55 | headline: { | ||
56 | fontSize: '24px', | ||
57 | marginTop: '38px', | ||
58 | marginBottom: '25px', | ||
59 | marginLeft: theme.workspaces.drawer.padding, | ||
60 | }, | ||
61 | headlineProBadge: { | ||
62 | marginRight: 15, | ||
63 | }, | ||
64 | workspacesSettingsButton: { | ||
65 | float: 'right', | ||
66 | marginRight: theme.workspaces.drawer.padding, | ||
67 | marginTop: '2px', | ||
68 | }, | ||
69 | workspacesSettingsButtonIcon: { | ||
70 | fill: theme.workspaces.drawer.buttons.color, | ||
71 | '&:hover': { | ||
72 | fill: theme.workspaces.drawer.buttons.hoverColor, | ||
73 | }, | ||
74 | }, | ||
75 | workspaces: { | ||
76 | height: 'auto', | ||
77 | }, | ||
78 | premiumAnnouncement: { | ||
79 | padding: '20px', | ||
80 | paddingTop: '0', | ||
81 | height: 'auto', | ||
82 | }, | ||
83 | premiumCtaButton: { | ||
84 | marginTop: '20px', | ||
85 | width: '100%', | ||
86 | color: 'white !important', | ||
87 | }, | ||
88 | addNewWorkspaceLabel: { | ||
89 | height: 'auto', | ||
90 | color: theme.workspaces.drawer.buttons.color, | ||
91 | marginTop: 40, | ||
92 | textAlign: 'center', | ||
93 | '& > svg': { | ||
94 | fill: theme.workspaces.drawer.buttons.color, | ||
95 | }, | ||
96 | '& > span': { | ||
97 | fontSize: '13px', | ||
98 | marginLeft: 10, | ||
99 | position: 'relative', | ||
100 | top: -3, | ||
101 | }, | ||
102 | '&:hover': { | ||
103 | color: theme.workspaces.drawer.buttons.hoverColor, | ||
104 | '& > svg': { | ||
105 | fill: theme.workspaces.drawer.buttons.hoverColor, | ||
106 | }, | ||
107 | }, | ||
108 | }, | ||
109 | }); | ||
110 | |||
111 | @injectSheet(styles) @observer | ||
112 | class WorkspaceDrawer extends Component { | ||
113 | static propTypes = { | ||
114 | classes: PropTypes.object.isRequired, | ||
115 | getServicesForWorkspace: PropTypes.func.isRequired, | ||
116 | onUpgradeAccountClick: PropTypes.func.isRequired, | ||
117 | }; | ||
118 | |||
119 | static contextTypes = { | ||
120 | intl: intlShape, | ||
121 | }; | ||
122 | |||
123 | componentDidMount() { | ||
124 | ReactTooltip.rebuild(); | ||
125 | } | ||
126 | |||
127 | render() { | ||
128 | const { | ||
129 | classes, | ||
130 | getServicesForWorkspace, | ||
131 | onUpgradeAccountClick, | ||
132 | } = this.props; | ||
133 | const { intl } = this.context; | ||
134 | const { | ||
135 | activeWorkspace, | ||
136 | isSwitchingWorkspace, | ||
137 | nextWorkspace, | ||
138 | workspaces, | ||
139 | } = workspaceStore; | ||
140 | const actualWorkspace = isSwitchingWorkspace ? nextWorkspace : activeWorkspace; | ||
141 | return ( | ||
142 | <div className={classes.drawer}> | ||
143 | <H1 className={classes.headline}> | ||
144 | {workspaceStore.isPremiumUpgradeRequired && ( | ||
145 | <span | ||
146 | className={classes.headlineProBadge} | ||
147 | data-tip={`${intl.formatMessage(messages.premiumFeatureBadge)}`} | ||
148 | > | ||
149 | <ProBadge /> | ||
150 | </span> | ||
151 | )} | ||
152 | {intl.formatMessage(messages.headline)} | ||
153 | <span | ||
154 | className={classes.workspacesSettingsButton} | ||
155 | onClick={() => { | ||
156 | workspaceActions.openWorkspaceSettings(); | ||
157 | gaEvent(GA_CATEGORY_WORKSPACES, 'settings', 'drawerHeadline'); | ||
158 | }} | ||
159 | data-tip={`${intl.formatMessage(messages.workspacesSettingsTooltip)}`} | ||
160 | > | ||
161 | <Icon | ||
162 | icon="mdiSettings" | ||
163 | size={1.5} | ||
164 | className={classes.workspacesSettingsButtonIcon} | ||
165 | /> | ||
166 | </span> | ||
167 | </H1> | ||
168 | {workspaceStore.isPremiumUpgradeRequired ? ( | ||
169 | <div className={classes.premiumAnnouncement}> | ||
170 | <FormattedHTMLMessage {...messages.workspaceFeatureInfo} /> | ||
171 | {workspaceStore.userHasWorkspaces ? ( | ||
172 | <Button | ||
173 | className={classes.premiumCtaButton} | ||
174 | buttonType="primary" | ||
175 | label={intl.formatMessage(messages.reactivatePremiumAccount)} | ||
176 | icon="mdiStar" | ||
177 | onClick={() => { | ||
178 | onUpgradeAccountClick(); | ||
179 | gaEvent('User', 'upgrade', 'workspaceDrawer'); | ||
180 | }} | ||
181 | /> | ||
182 | ) : ( | ||
183 | <Button | ||
184 | className={classes.premiumCtaButton} | ||
185 | buttonType="primary" | ||
186 | label={intl.formatMessage(messages.premiumCtaButtonLabel)} | ||
187 | icon="mdiPlusBox" | ||
188 | onClick={() => { | ||
189 | workspaceActions.openWorkspaceSettings(); | ||
190 | gaEvent(GA_CATEGORY_WORKSPACES, 'add', 'drawerPremiumCta'); | ||
191 | }} | ||
192 | /> | ||
193 | )} | ||
194 | </div> | ||
195 | ) : ( | ||
196 | <div className={classes.workspaces}> | ||
197 | <WorkspaceDrawerItem | ||
198 | name={intl.formatMessage(messages.allServices)} | ||
199 | onClick={() => { | ||
200 | workspaceActions.deactivate(); | ||
201 | workspaceActions.toggleWorkspaceDrawer(); | ||
202 | gaEvent(GA_CATEGORY_WORKSPACES, 'switch', 'drawer'); | ||
203 | }} | ||
204 | services={getServicesForWorkspace(null)} | ||
205 | isActive={actualWorkspace == null} | ||
206 | /> | ||
207 | {workspaces.map(workspace => ( | ||
208 | <WorkspaceDrawerItem | ||
209 | key={workspace.id} | ||
210 | name={workspace.name} | ||
211 | isActive={actualWorkspace === workspace} | ||
212 | onClick={() => { | ||
213 | if (actualWorkspace === workspace) return; | ||
214 | workspaceActions.activate({ workspace }); | ||
215 | workspaceActions.toggleWorkspaceDrawer(); | ||
216 | gaEvent(GA_CATEGORY_WORKSPACES, 'switch', 'drawer'); | ||
217 | }} | ||
218 | onContextMenuEditClick={() => workspaceActions.edit({ workspace })} | ||
219 | services={getServicesForWorkspace(workspace)} | ||
220 | /> | ||
221 | ))} | ||
222 | <div | ||
223 | className={classes.addNewWorkspaceLabel} | ||
224 | onClick={() => { | ||
225 | workspaceActions.openWorkspaceSettings(); | ||
226 | gaEvent(GA_CATEGORY_WORKSPACES, 'add', 'drawerAddLabel'); | ||
227 | }} | ||
228 | > | ||
229 | <Icon | ||
230 | icon="mdiPlusBox" | ||
231 | size={1} | ||
232 | className={classes.workspacesSettingsButtonIcon} | ||
233 | /> | ||
234 | <span> | ||
235 | {intl.formatMessage(messages.addNewWorkspaceLabel)} | ||
236 | </span> | ||
237 | </div> | ||
238 | </div> | ||
239 | )} | ||
240 | <ReactTooltip place="right" type="dark" effect="solid" /> | ||
241 | </div> | ||
242 | ); | ||
243 | } | ||
244 | } | ||
245 | |||
246 | 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 @@ | |||
1 | import { remote } from 'electron'; | ||
2 | import React, { Component } from 'react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import { observer } from 'mobx-react'; | ||
5 | import injectSheet from 'react-jss'; | ||
6 | import classnames from 'classnames'; | ||
7 | import { defineMessages, intlShape } from 'react-intl'; | ||
8 | |||
9 | const { Menu } = remote; | ||
10 | |||
11 | const messages = defineMessages({ | ||
12 | noServicesAddedYet: { | ||
13 | id: 'workspaceDrawer.item.noServicesAddedYet', | ||
14 | defaultMessage: '!!!No services added yet', | ||
15 | }, | ||
16 | contextMenuEdit: { | ||
17 | id: 'workspaceDrawer.item.contextMenuEdit', | ||
18 | defaultMessage: '!!!edit', | ||
19 | }, | ||
20 | }); | ||
21 | |||
22 | const styles = theme => ({ | ||
23 | item: { | ||
24 | height: '67px', | ||
25 | padding: `15px ${theme.workspaces.drawer.padding}px`, | ||
26 | borderBottom: `1px solid ${theme.workspaces.drawer.listItem.border}`, | ||
27 | transition: 'background-color 300ms ease-out', | ||
28 | '&:first-child': { | ||
29 | borderTop: `1px solid ${theme.workspaces.drawer.listItem.border}`, | ||
30 | }, | ||
31 | '&:hover': { | ||
32 | backgroundColor: theme.workspaces.drawer.listItem.hoverBackground, | ||
33 | }, | ||
34 | }, | ||
35 | isActiveItem: { | ||
36 | backgroundColor: theme.workspaces.drawer.listItem.activeBackground, | ||
37 | '&:hover': { | ||
38 | backgroundColor: theme.workspaces.drawer.listItem.activeBackground, | ||
39 | }, | ||
40 | }, | ||
41 | name: { | ||
42 | marginTop: '4px', | ||
43 | color: theme.workspaces.drawer.listItem.name.color, | ||
44 | }, | ||
45 | activeName: { | ||
46 | color: theme.workspaces.drawer.listItem.name.activeColor, | ||
47 | }, | ||
48 | services: { | ||
49 | display: 'block', | ||
50 | fontSize: '11px', | ||
51 | marginTop: '5px', | ||
52 | color: theme.workspaces.drawer.listItem.services.color, | ||
53 | whiteSpace: 'nowrap', | ||
54 | textOverflow: 'ellipsis', | ||
55 | overflow: 'hidden', | ||
56 | lineHeight: '15px', | ||
57 | }, | ||
58 | activeServices: { | ||
59 | color: theme.workspaces.drawer.listItem.services.active, | ||
60 | }, | ||
61 | }); | ||
62 | |||
63 | @injectSheet(styles) @observer | ||
64 | class WorkspaceDrawerItem extends Component { | ||
65 | static propTypes = { | ||
66 | classes: PropTypes.object.isRequired, | ||
67 | isActive: PropTypes.bool.isRequired, | ||
68 | name: PropTypes.string.isRequired, | ||
69 | onClick: PropTypes.func.isRequired, | ||
70 | services: PropTypes.arrayOf(PropTypes.string).isRequired, | ||
71 | onContextMenuEditClick: PropTypes.func, | ||
72 | }; | ||
73 | |||
74 | static defaultProps = { | ||
75 | onContextMenuEditClick: null, | ||
76 | }; | ||
77 | |||
78 | static contextTypes = { | ||
79 | intl: intlShape, | ||
80 | }; | ||
81 | |||
82 | render() { | ||
83 | const { | ||
84 | classes, | ||
85 | isActive, | ||
86 | name, | ||
87 | onClick, | ||
88 | onContextMenuEditClick, | ||
89 | services, | ||
90 | } = this.props; | ||
91 | const { intl } = this.context; | ||
92 | |||
93 | const contextMenuTemplate = [{ | ||
94 | label: name, | ||
95 | enabled: false, | ||
96 | }, { | ||
97 | type: 'separator', | ||
98 | }, { | ||
99 | label: intl.formatMessage(messages.contextMenuEdit), | ||
100 | click: onContextMenuEditClick, | ||
101 | }]; | ||
102 | |||
103 | const contextMenu = Menu.buildFromTemplate(contextMenuTemplate); | ||
104 | |||
105 | return ( | ||
106 | <div | ||
107 | className={classnames([ | ||
108 | classes.item, | ||
109 | isActive ? classes.isActiveItem : null, | ||
110 | ])} | ||
111 | onClick={onClick} | ||
112 | onContextMenu={() => ( | ||
113 | onContextMenuEditClick && contextMenu.popup(remote.getCurrentWindow()) | ||
114 | )} | ||
115 | > | ||
116 | <span | ||
117 | className={classnames([ | ||
118 | classes.name, | ||
119 | isActive ? classes.activeName : null, | ||
120 | ])} | ||
121 | > | ||
122 | {name} | ||
123 | </span> | ||
124 | <span | ||
125 | className={classnames([ | ||
126 | classes.services, | ||
127 | isActive ? classes.activeServices : null, | ||
128 | ])} | ||
129 | > | ||
130 | {services.length ? services.join(', ') : intl.formatMessage(messages.noServicesAddedYet)} | ||
131 | </span> | ||
132 | </div> | ||
133 | ); | ||
134 | } | ||
135 | } | ||
136 | |||
137 | 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { intlShape } from 'react-intl'; | ||
4 | import { observer } from 'mobx-react'; | ||
5 | import injectSheet from 'react-jss'; | ||
6 | |||
7 | import Workspace from '../models/Workspace'; | ||
8 | |||
9 | const styles = theme => ({ | ||
10 | row: { | ||
11 | height: theme.workspaces.settings.listItems.height, | ||
12 | borderBottom: `1px solid ${theme.workspaces.settings.listItems.borderColor}`, | ||
13 | '&:hover': { | ||
14 | background: theme.workspaces.settings.listItems.hoverBgColor, | ||
15 | }, | ||
16 | }, | ||
17 | columnName: {}, | ||
18 | }); | ||
19 | |||
20 | @injectSheet(styles) @observer | ||
21 | class WorkspaceItem extends Component { | ||
22 | static propTypes = { | ||
23 | classes: PropTypes.object.isRequired, | ||
24 | workspace: PropTypes.instanceOf(Workspace).isRequired, | ||
25 | onItemClick: PropTypes.func.isRequired, | ||
26 | }; | ||
27 | |||
28 | static contextTypes = { | ||
29 | intl: intlShape, | ||
30 | }; | ||
31 | |||
32 | render() { | ||
33 | const { classes, workspace, onItemClick } = this.props; | ||
34 | |||
35 | return ( | ||
36 | <tr className={classes.row}> | ||
37 | <td onClick={() => onItemClick(workspace)}> | ||
38 | {workspace.name} | ||
39 | </td> | ||
40 | </tr> | ||
41 | ); | ||
42 | } | ||
43 | } | ||
44 | |||
45 | 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import injectSheet from 'react-jss'; | ||
5 | import classnames from 'classnames'; | ||
6 | import { Toggle } from '@meetfranz/forms'; | ||
7 | |||
8 | import Service from '../../../models/Service'; | ||
9 | import ServiceIcon from '../../../components/ui/ServiceIcon'; | ||
10 | |||
11 | const styles = theme => ({ | ||
12 | listItem: { | ||
13 | height: theme.workspaces.settings.listItems.height, | ||
14 | borderBottom: `1px solid ${theme.workspaces.settings.listItems.borderColor}`, | ||
15 | display: 'flex', | ||
16 | alignItems: 'center', | ||
17 | }, | ||
18 | serviceIcon: { | ||
19 | padding: theme.workspaces.settings.listItems.padding, | ||
20 | }, | ||
21 | toggle: { | ||
22 | height: 'auto', | ||
23 | margin: 0, | ||
24 | }, | ||
25 | label: { | ||
26 | padding: theme.workspaces.settings.listItems.padding, | ||
27 | flexGrow: 1, | ||
28 | }, | ||
29 | disabledLabel: { | ||
30 | color: theme.workspaces.settings.listItems.disabled.color, | ||
31 | }, | ||
32 | }); | ||
33 | |||
34 | @injectSheet(styles) @observer | ||
35 | class WorkspaceServiceListItem extends Component { | ||
36 | static propTypes = { | ||
37 | classes: PropTypes.object.isRequired, | ||
38 | isInWorkspace: PropTypes.bool.isRequired, | ||
39 | onToggle: PropTypes.func.isRequired, | ||
40 | service: PropTypes.instanceOf(Service).isRequired, | ||
41 | }; | ||
42 | |||
43 | render() { | ||
44 | const { | ||
45 | classes, | ||
46 | isInWorkspace, | ||
47 | onToggle, | ||
48 | service, | ||
49 | } = this.props; | ||
50 | |||
51 | return ( | ||
52 | <div className={classes.listItem}> | ||
53 | <ServiceIcon | ||
54 | className={classes.serviceIcon} | ||
55 | service={service} | ||
56 | /> | ||
57 | <span | ||
58 | className={classnames([ | ||
59 | classes.label, | ||
60 | service.isEnabled ? null : classes.disabledLabel, | ||
61 | ])} | ||
62 | > | ||
63 | {service.name} | ||
64 | </span> | ||
65 | <Toggle | ||
66 | className={classes.toggle} | ||
67 | checked={isInWorkspace} | ||
68 | onChange={onToggle} | ||
69 | /> | ||
70 | </div> | ||
71 | ); | ||
72 | } | ||
73 | } | ||
74 | |||
75 | 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import injectSheet from 'react-jss'; | ||
5 | import classnames from 'classnames'; | ||
6 | import { Loader } from '@meetfranz/ui'; | ||
7 | import { defineMessages, intlShape } from 'react-intl'; | ||
8 | |||
9 | import { workspaceStore } from '../index'; | ||
10 | |||
11 | const messages = defineMessages({ | ||
12 | switchingTo: { | ||
13 | id: 'workspaces.switchingIndicator.switchingTo', | ||
14 | defaultMessage: '!!!Switching to', | ||
15 | }, | ||
16 | }); | ||
17 | |||
18 | const styles = theme => ({ | ||
19 | wrapper: { | ||
20 | display: 'flex', | ||
21 | alignItems: 'flex-start', | ||
22 | position: 'absolute', | ||
23 | transition: 'width 0.5s ease', | ||
24 | width: '100%', | ||
25 | marginTop: '20px', | ||
26 | }, | ||
27 | wrapperWhenDrawerIsOpen: { | ||
28 | width: `calc(100% - ${theme.workspaces.drawer.width}px)`, | ||
29 | }, | ||
30 | component: { | ||
31 | background: 'rgba(20, 20, 20, 0.4)', | ||
32 | padding: '10px 20px', | ||
33 | display: 'flex', | ||
34 | width: 'auto', | ||
35 | height: 'auto', | ||
36 | margin: [0, 'auto'], | ||
37 | borderRadius: 6, | ||
38 | alignItems: 'center', | ||
39 | zIndex: 200, | ||
40 | }, | ||
41 | spinner: { | ||
42 | width: 40, | ||
43 | height: 40, | ||
44 | marginRight: 10, | ||
45 | }, | ||
46 | message: { | ||
47 | fontSize: 16, | ||
48 | whiteSpace: 'nowrap', | ||
49 | color: theme.colorAppLoaderSpinner, | ||
50 | }, | ||
51 | }); | ||
52 | |||
53 | @injectSheet(styles) @observer | ||
54 | class WorkspaceSwitchingIndicator extends Component { | ||
55 | static propTypes = { | ||
56 | classes: PropTypes.object.isRequired, | ||
57 | theme: PropTypes.object.isRequired, | ||
58 | }; | ||
59 | |||
60 | static contextTypes = { | ||
61 | intl: intlShape, | ||
62 | }; | ||
63 | |||
64 | render() { | ||
65 | const { classes, theme } = this.props; | ||
66 | const { intl } = this.context; | ||
67 | const { isSwitchingWorkspace, isWorkspaceDrawerOpen, nextWorkspace } = workspaceStore; | ||
68 | if (!isSwitchingWorkspace) return null; | ||
69 | const nextWorkspaceName = nextWorkspace ? nextWorkspace.name : 'All services'; | ||
70 | return ( | ||
71 | <div | ||
72 | className={classnames([ | ||
73 | classes.wrapper, | ||
74 | isWorkspaceDrawerOpen ? classes.wrapperWhenDrawerIsOpen : null, | ||
75 | ])} | ||
76 | > | ||
77 | <div className={classes.component}> | ||
78 | <Loader | ||
79 | className={classes.spinner} | ||
80 | color={theme.workspaces.switchingIndicator.spinnerColor} | ||
81 | /> | ||
82 | <p className={classes.message}> | ||
83 | {`${intl.formatMessage(messages.switchingTo)} ${nextWorkspaceName}`} | ||
84 | </p> | ||
85 | </div> | ||
86 | </div> | ||
87 | ); | ||
88 | } | ||
89 | } | ||
90 | |||
91 | 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..09c98ab8c --- /dev/null +++ b/src/features/workspaces/components/WorkspacesDashboard.js | |||
@@ -0,0 +1,209 @@ | |||
1 | import React, { Component, Fragment } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import injectSheet from 'react-jss'; | ||
6 | import { Infobox } from '@meetfranz/ui'; | ||
7 | |||
8 | import Loader from '../../../components/ui/Loader'; | ||
9 | import WorkspaceItem from './WorkspaceItem'; | ||
10 | import CreateWorkspaceForm from './CreateWorkspaceForm'; | ||
11 | import Request from '../../../stores/lib/Request'; | ||
12 | import Appear from '../../../components/ui/effects/Appear'; | ||
13 | import { workspaceStore } from '../index'; | ||
14 | import PremiumFeatureContainer from '../../../components/ui/PremiumFeatureContainer'; | ||
15 | |||
16 | const messages = defineMessages({ | ||
17 | headline: { | ||
18 | id: 'settings.workspaces.headline', | ||
19 | defaultMessage: '!!!Your workspaces', | ||
20 | }, | ||
21 | noServicesAdded: { | ||
22 | id: 'settings.workspaces.noWorkspacesAdded', | ||
23 | defaultMessage: '!!!You haven\'t added any workspaces yet.', | ||
24 | }, | ||
25 | workspacesRequestFailed: { | ||
26 | id: 'settings.workspaces.workspacesRequestFailed', | ||
27 | defaultMessage: '!!!Could not load your workspaces', | ||
28 | }, | ||
29 | tryReloadWorkspaces: { | ||
30 | id: 'settings.workspaces.tryReloadWorkspaces', | ||
31 | defaultMessage: '!!!Try again', | ||
32 | }, | ||
33 | updatedInfo: { | ||
34 | id: 'settings.workspaces.updatedInfo', | ||
35 | defaultMessage: '!!!Your changes have been saved', | ||
36 | }, | ||
37 | deletedInfo: { | ||
38 | id: 'settings.workspaces.deletedInfo', | ||
39 | defaultMessage: '!!!Workspace has been deleted', | ||
40 | }, | ||
41 | workspaceFeatureInfo: { | ||
42 | id: 'settings.workspaces.workspaceFeatureInfo', | ||
43 | defaultMessage: '!!!Info about workspace feature', | ||
44 | }, | ||
45 | workspaceFeatureHeadline: { | ||
46 | id: 'settings.workspaces.workspaceFeatureHeadline', | ||
47 | defaultMessage: '!!!Less is More: Introducing Franz Workspaces', | ||
48 | }, | ||
49 | }); | ||
50 | |||
51 | const styles = theme => ({ | ||
52 | table: { | ||
53 | width: '100%', | ||
54 | '& td': { | ||
55 | padding: '10px', | ||
56 | }, | ||
57 | }, | ||
58 | createForm: { | ||
59 | height: 'auto', | ||
60 | }, | ||
61 | appear: { | ||
62 | height: 'auto', | ||
63 | }, | ||
64 | premiumAnnouncement: { | ||
65 | padding: '20px', | ||
66 | backgroundColor: '#3498db', | ||
67 | marginLeft: '-20px', | ||
68 | marginBottom: '20px', | ||
69 | height: 'auto', | ||
70 | color: 'white', | ||
71 | borderRadius: theme.borderRadius, | ||
72 | }, | ||
73 | }); | ||
74 | |||
75 | @injectSheet(styles) @observer | ||
76 | class WorkspacesDashboard extends Component { | ||
77 | static propTypes = { | ||
78 | classes: PropTypes.object.isRequired, | ||
79 | getUserWorkspacesRequest: PropTypes.instanceOf(Request).isRequired, | ||
80 | createWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, | ||
81 | deleteWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, | ||
82 | updateWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, | ||
83 | onCreateWorkspaceSubmit: PropTypes.func.isRequired, | ||
84 | onWorkspaceClick: PropTypes.func.isRequired, | ||
85 | workspaces: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
86 | }; | ||
87 | |||
88 | static contextTypes = { | ||
89 | intl: intlShape, | ||
90 | }; | ||
91 | |||
92 | render() { | ||
93 | const { | ||
94 | classes, | ||
95 | getUserWorkspacesRequest, | ||
96 | createWorkspaceRequest, | ||
97 | deleteWorkspaceRequest, | ||
98 | updateWorkspaceRequest, | ||
99 | onCreateWorkspaceSubmit, | ||
100 | onWorkspaceClick, | ||
101 | workspaces, | ||
102 | } = this.props; | ||
103 | const { intl } = this.context; | ||
104 | return ( | ||
105 | <div className="settings__main"> | ||
106 | <div className="settings__header"> | ||
107 | <h1>{intl.formatMessage(messages.headline)}</h1> | ||
108 | </div> | ||
109 | <div className="settings__body"> | ||
110 | |||
111 | {/* ===== Workspace updated info ===== */} | ||
112 | {updateWorkspaceRequest.wasExecuted && updateWorkspaceRequest.result && ( | ||
113 | <Appear className={classes.appear}> | ||
114 | <Infobox | ||
115 | type="success" | ||
116 | icon="mdiCheckboxMarkedCircleOutline" | ||
117 | dismissable | ||
118 | onUnmount={updateWorkspaceRequest.reset} | ||
119 | > | ||
120 | {intl.formatMessage(messages.updatedInfo)} | ||
121 | </Infobox> | ||
122 | </Appear> | ||
123 | )} | ||
124 | |||
125 | {/* ===== Workspace deleted info ===== */} | ||
126 | {deleteWorkspaceRequest.wasExecuted && deleteWorkspaceRequest.result && ( | ||
127 | <Appear className={classes.appear}> | ||
128 | <Infobox | ||
129 | type="success" | ||
130 | icon="mdiCheckboxMarkedCircleOutline" | ||
131 | dismissable | ||
132 | onUnmount={deleteWorkspaceRequest.reset} | ||
133 | > | ||
134 | {intl.formatMessage(messages.deletedInfo)} | ||
135 | </Infobox> | ||
136 | </Appear> | ||
137 | )} | ||
138 | |||
139 | {workspaceStore.isPremiumUpgradeRequired && ( | ||
140 | <div className={classes.premiumAnnouncement}> | ||
141 | <h2>{intl.formatMessage(messages.workspaceFeatureHeadline)}</h2> | ||
142 | <p>{intl.formatMessage(messages.workspaceFeatureInfo)}</p> | ||
143 | </div> | ||
144 | )} | ||
145 | |||
146 | <PremiumFeatureContainer | ||
147 | condition={workspaceStore.isPremiumFeature} | ||
148 | gaEventInfo={{ category: 'User', event: 'upgrade', label: 'workspaces' }} | ||
149 | > | ||
150 | {/* ===== Create workspace form ===== */} | ||
151 | <div className={classes.createForm}> | ||
152 | <CreateWorkspaceForm | ||
153 | isSubmitting={createWorkspaceRequest.isExecuting} | ||
154 | onSubmit={onCreateWorkspaceSubmit} | ||
155 | /> | ||
156 | </div> | ||
157 | {getUserWorkspacesRequest.isExecuting ? ( | ||
158 | <Loader /> | ||
159 | ) : ( | ||
160 | <Fragment> | ||
161 | {/* ===== Workspace could not be loaded error ===== */} | ||
162 | {getUserWorkspacesRequest.error ? ( | ||
163 | <Infobox | ||
164 | icon="alert" | ||
165 | type="danger" | ||
166 | ctaLabel={intl.formatMessage(messages.tryReloadWorkspaces)} | ||
167 | ctaLoading={getUserWorkspacesRequest.isExecuting} | ||
168 | ctaOnClick={getUserWorkspacesRequest.retry} | ||
169 | > | ||
170 | {intl.formatMessage(messages.workspacesRequestFailed)} | ||
171 | </Infobox> | ||
172 | ) : ( | ||
173 | <Fragment> | ||
174 | {workspaces.length === 0 ? ( | ||
175 | <div className="align-middle settings__empty-state"> | ||
176 | {/* ===== Workspaces empty state ===== */} | ||
177 | <p className="settings__empty-text"> | ||
178 | <span className="emoji"> | ||
179 | <img src="./assets/images/emoji/sad.png" alt="" /> | ||
180 | </span> | ||
181 | {intl.formatMessage(messages.noServicesAdded)} | ||
182 | </p> | ||
183 | </div> | ||
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> | ||
199 | )} | ||
200 | </Fragment> | ||
201 | )} | ||
202 | </PremiumFeatureContainer> | ||
203 | </div> | ||
204 | </div> | ||
205 | ); | ||
206 | } | ||
207 | } | ||
208 | |||
209 | 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import { inject, observer } from 'mobx-react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | |||
5 | import ErrorBoundary from '../../../components/util/ErrorBoundary'; | ||
6 | import EditWorkspaceForm from '../components/EditWorkspaceForm'; | ||
7 | import ServicesStore from '../../../stores/ServicesStore'; | ||
8 | import Workspace from '../models/Workspace'; | ||
9 | import { workspaceStore } from '../index'; | ||
10 | import { deleteWorkspaceRequest, updateWorkspaceRequest } from '../api'; | ||
11 | |||
12 | @inject('stores', 'actions') @observer | ||
13 | class EditWorkspaceScreen extends Component { | ||
14 | static propTypes = { | ||
15 | actions: PropTypes.shape({ | ||
16 | workspace: PropTypes.shape({ | ||
17 | delete: PropTypes.func.isRequired, | ||
18 | }), | ||
19 | }).isRequired, | ||
20 | stores: PropTypes.shape({ | ||
21 | services: PropTypes.instanceOf(ServicesStore).isRequired, | ||
22 | }).isRequired, | ||
23 | }; | ||
24 | |||
25 | onDelete = () => { | ||
26 | const { workspaceBeingEdited } = workspaceStore; | ||
27 | const { actions } = this.props; | ||
28 | if (!workspaceBeingEdited) return null; | ||
29 | actions.workspaces.delete({ workspace: workspaceBeingEdited }); | ||
30 | }; | ||
31 | |||
32 | onSave = (values) => { | ||
33 | const { workspaceBeingEdited } = workspaceStore; | ||
34 | const { actions } = this.props; | ||
35 | const workspace = new Workspace( | ||
36 | Object.assign({}, workspaceBeingEdited, values), | ||
37 | ); | ||
38 | actions.workspaces.update({ workspace }); | ||
39 | }; | ||
40 | |||
41 | render() { | ||
42 | const { workspaceBeingEdited } = workspaceStore; | ||
43 | const { stores } = this.props; | ||
44 | if (!workspaceBeingEdited) return null; | ||
45 | return ( | ||
46 | <ErrorBoundary> | ||
47 | <EditWorkspaceForm | ||
48 | workspace={workspaceBeingEdited} | ||
49 | services={stores.services.all} | ||
50 | onDelete={this.onDelete} | ||
51 | onSave={this.onSave} | ||
52 | updateWorkspaceRequest={updateWorkspaceRequest} | ||
53 | deleteWorkspaceRequest={deleteWorkspaceRequest} | ||
54 | /> | ||
55 | </ErrorBoundary> | ||
56 | ); | ||
57 | } | ||
58 | } | ||
59 | |||
60 | 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import { inject, observer } from 'mobx-react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import WorkspacesDashboard from '../components/WorkspacesDashboard'; | ||
5 | import ErrorBoundary from '../../../components/util/ErrorBoundary'; | ||
6 | import { workspaceStore } from '../index'; | ||
7 | import { | ||
8 | createWorkspaceRequest, | ||
9 | deleteWorkspaceRequest, | ||
10 | getUserWorkspacesRequest, | ||
11 | updateWorkspaceRequest, | ||
12 | } from '../api'; | ||
13 | |||
14 | @inject('actions') @observer | ||
15 | class WorkspacesScreen extends Component { | ||
16 | static propTypes = { | ||
17 | actions: PropTypes.shape({ | ||
18 | workspace: PropTypes.shape({ | ||
19 | edit: PropTypes.func.isRequired, | ||
20 | }), | ||
21 | }).isRequired, | ||
22 | }; | ||
23 | |||
24 | render() { | ||
25 | const { actions } = this.props; | ||
26 | return ( | ||
27 | <ErrorBoundary> | ||
28 | <WorkspacesDashboard | ||
29 | workspaces={workspaceStore.workspaces} | ||
30 | getUserWorkspacesRequest={getUserWorkspacesRequest} | ||
31 | createWorkspaceRequest={createWorkspaceRequest} | ||
32 | deleteWorkspaceRequest={deleteWorkspaceRequest} | ||
33 | updateWorkspaceRequest={updateWorkspaceRequest} | ||
34 | onCreateWorkspaceSubmit={data => actions.workspaces.create(data)} | ||
35 | onWorkspaceClick={w => actions.workspaces.edit({ workspace: w })} | ||
36 | /> | ||
37 | </ErrorBoundary> | ||
38 | ); | ||
39 | } | ||
40 | } | ||
41 | |||
42 | 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 @@ | |||
1 | import { reaction } from 'mobx'; | ||
2 | import WorkspacesStore from './store'; | ||
3 | import { resetApiRequests } from './api'; | ||
4 | |||
5 | const debug = require('debug')('Franz:feature:workspaces'); | ||
6 | |||
7 | export const GA_CATEGORY_WORKSPACES = 'Workspaces'; | ||
8 | |||
9 | export const workspaceStore = new WorkspacesStore(); | ||
10 | |||
11 | export default function initWorkspaces(stores, actions) { | ||
12 | stores.workspaces = workspaceStore; | ||
13 | const { features } = stores; | ||
14 | |||
15 | // Toggle workspace feature | ||
16 | reaction( | ||
17 | () => features.features.isWorkspaceEnabled, | ||
18 | (isEnabled) => { | ||
19 | if (isEnabled && !workspaceStore.isFeatureActive) { | ||
20 | debug('Initializing `workspaces` feature'); | ||
21 | workspaceStore.start(stores, actions); | ||
22 | } else if (workspaceStore.isFeatureActive) { | ||
23 | debug('Disabling `workspaces` feature'); | ||
24 | workspaceStore.stop(); | ||
25 | resetApiRequests(); | ||
26 | } | ||
27 | }, | ||
28 | { | ||
29 | fireImmediately: true, | ||
30 | }, | ||
31 | ); | ||
32 | } | ||
33 | |||
34 | export const WORKSPACES_ROUTES = { | ||
35 | ROOT: '/settings/workspaces', | ||
36 | EDIT: '/settings/workspaces/:action/:id', | ||
37 | }; | ||
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 @@ | |||
1 | import { observable } from 'mobx'; | ||
2 | |||
3 | export 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/store.js b/src/features/workspaces/store.js new file mode 100644 index 000000000..e11513d1f --- /dev/null +++ b/src/features/workspaces/store.js | |||
@@ -0,0 +1,323 @@ | |||
1 | import { | ||
2 | computed, | ||
3 | observable, | ||
4 | action, | ||
5 | } from 'mobx'; | ||
6 | import localStorage from 'mobx-localstorage'; | ||
7 | import { matchRoute } from '../../helpers/routing-helpers'; | ||
8 | import { workspaceActions } from './actions'; | ||
9 | import { FeatureStore } from '../utils/FeatureStore'; | ||
10 | import { | ||
11 | createWorkspaceRequest, | ||
12 | deleteWorkspaceRequest, | ||
13 | getUserWorkspacesRequest, | ||
14 | updateWorkspaceRequest, | ||
15 | } from './api'; | ||
16 | import { WORKSPACES_ROUTES } from './index'; | ||
17 | import { createReactions } from '../../stores/lib/Reaction'; | ||
18 | import { createActionBindings } from '../utils/ActionBinding'; | ||
19 | |||
20 | const debug = require('debug')('Franz:feature:workspaces:store'); | ||
21 | |||
22 | export default class WorkspacesStore extends FeatureStore { | ||
23 | @observable isFeatureEnabled = false; | ||
24 | |||
25 | @observable isFeatureActive = false; | ||
26 | |||
27 | @observable isPremiumFeature = true; | ||
28 | |||
29 | @observable isPremiumUpgradeRequired = true; | ||
30 | |||
31 | @observable activeWorkspace = null; | ||
32 | |||
33 | @observable nextWorkspace = null; | ||
34 | |||
35 | @observable workspaceBeingEdited = null; | ||
36 | |||
37 | @observable isSwitchingWorkspace = false; | ||
38 | |||
39 | @observable isWorkspaceDrawerOpen = false; | ||
40 | |||
41 | @observable isSettingsRouteActive = null; | ||
42 | |||
43 | @computed get workspaces() { | ||
44 | if (!this.isFeatureActive) return []; | ||
45 | return getUserWorkspacesRequest.result || []; | ||
46 | } | ||
47 | |||
48 | @computed get settings() { | ||
49 | return localStorage.getItem('workspaces') || {}; | ||
50 | } | ||
51 | |||
52 | @computed get userHasWorkspaces() { | ||
53 | return getUserWorkspacesRequest.wasExecuted && this.workspaces.length > 0; | ||
54 | } | ||
55 | |||
56 | @computed get isUserAllowedToUseFeature() { | ||
57 | return !this.isPremiumUpgradeRequired; | ||
58 | } | ||
59 | |||
60 | // ========== PRIVATE PROPERTIES ========= // | ||
61 | |||
62 | _wasDrawerOpenBeforeSettingsRoute = null; | ||
63 | |||
64 | _freeUserActions = []; | ||
65 | |||
66 | _premiumUserActions = []; | ||
67 | |||
68 | _allActions = []; | ||
69 | |||
70 | _freeUserReactions = []; | ||
71 | |||
72 | _premiumUserReactions = []; | ||
73 | |||
74 | _allReactions = []; | ||
75 | |||
76 | // ========== PUBLIC API ========= // | ||
77 | |||
78 | start(stores, actions) { | ||
79 | debug('WorkspacesStore::start'); | ||
80 | this.stores = stores; | ||
81 | this.actions = actions; | ||
82 | |||
83 | // ACTIONS | ||
84 | |||
85 | this._freeUserActions = createActionBindings([ | ||
86 | [workspaceActions.toggleWorkspaceDrawer, this._toggleWorkspaceDrawer], | ||
87 | [workspaceActions.openWorkspaceSettings, this._openWorkspaceSettings], | ||
88 | ]); | ||
89 | this._premiumUserActions = createActionBindings([ | ||
90 | [workspaceActions.edit, this._edit], | ||
91 | [workspaceActions.create, this._create], | ||
92 | [workspaceActions.delete, this._delete], | ||
93 | [workspaceActions.update, this._update], | ||
94 | [workspaceActions.activate, this._setActiveWorkspace], | ||
95 | [workspaceActions.deactivate, this._deactivateActiveWorkspace], | ||
96 | ]); | ||
97 | this._allActions = this._freeUserActions.concat(this._premiumUserActions); | ||
98 | this._registerActions(this._allActions); | ||
99 | |||
100 | // REACTIONS | ||
101 | |||
102 | this._freeUserReactions = createReactions([ | ||
103 | this._stopPremiumActionsAndReactions, | ||
104 | this._openDrawerWithSettingsReaction, | ||
105 | this._setFeatureEnabledReaction, | ||
106 | this._setIsPremiumFeatureReaction, | ||
107 | this._cleanupInvalidServiceReferences, | ||
108 | ]); | ||
109 | this._premiumUserReactions = createReactions([ | ||
110 | this._setActiveServiceOnWorkspaceSwitchReaction, | ||
111 | this._activateLastUsedWorkspaceReaction, | ||
112 | this._setWorkspaceBeingEditedReaction, | ||
113 | ]); | ||
114 | this._allReactions = this._freeUserReactions.concat(this._premiumUserReactions); | ||
115 | |||
116 | this._registerReactions(this._allReactions); | ||
117 | |||
118 | getUserWorkspacesRequest.execute(); | ||
119 | this.isFeatureActive = true; | ||
120 | } | ||
121 | |||
122 | stop() { | ||
123 | super.stop(); | ||
124 | debug('WorkspacesStore::stop'); | ||
125 | this.isFeatureActive = false; | ||
126 | this.activeWorkspace = null; | ||
127 | this.nextWorkspace = null; | ||
128 | this.workspaceBeingEdited = null; | ||
129 | this.isSwitchingWorkspace = false; | ||
130 | this.isWorkspaceDrawerOpen = false; | ||
131 | } | ||
132 | |||
133 | filterServicesByActiveWorkspace = (services) => { | ||
134 | const { activeWorkspace, isFeatureActive } = this; | ||
135 | if (isFeatureActive && activeWorkspace) { | ||
136 | return this.getWorkspaceServices(activeWorkspace); | ||
137 | } | ||
138 | return services; | ||
139 | }; | ||
140 | |||
141 | getWorkspaceServices(workspace) { | ||
142 | const { services } = this.stores; | ||
143 | return workspace.services.map(id => services.one(id)).filter(s => !!s); | ||
144 | } | ||
145 | |||
146 | // ========== PRIVATE METHODS ========= // | ||
147 | |||
148 | _getWorkspaceById = id => this.workspaces.find(w => w.id === id); | ||
149 | |||
150 | _updateSettings = (changes) => { | ||
151 | localStorage.setItem('workspaces', { | ||
152 | ...this.settings, | ||
153 | ...changes, | ||
154 | }); | ||
155 | }; | ||
156 | |||
157 | // Actions | ||
158 | |||
159 | @action _edit = ({ workspace }) => { | ||
160 | this.stores.router.push(`/settings/workspaces/edit/${workspace.id}`); | ||
161 | }; | ||
162 | |||
163 | @action _create = async ({ name }) => { | ||
164 | try { | ||
165 | const workspace = await createWorkspaceRequest.execute(name); | ||
166 | await getUserWorkspacesRequest.result.push(workspace); | ||
167 | this._edit({ workspace }); | ||
168 | } catch (error) { | ||
169 | throw error; | ||
170 | } | ||
171 | }; | ||
172 | |||
173 | @action _delete = async ({ workspace }) => { | ||
174 | try { | ||
175 | await deleteWorkspaceRequest.execute(workspace); | ||
176 | await getUserWorkspacesRequest.result.remove(workspace); | ||
177 | this.stores.router.push('/settings/workspaces'); | ||
178 | } catch (error) { | ||
179 | throw error; | ||
180 | } | ||
181 | }; | ||
182 | |||
183 | @action _update = async ({ workspace }) => { | ||
184 | try { | ||
185 | await updateWorkspaceRequest.execute(workspace); | ||
186 | // Path local result optimistically | ||
187 | const localWorkspace = this._getWorkspaceById(workspace.id); | ||
188 | Object.assign(localWorkspace, workspace); | ||
189 | this.stores.router.push('/settings/workspaces'); | ||
190 | } catch (error) { | ||
191 | throw error; | ||
192 | } | ||
193 | }; | ||
194 | |||
195 | @action _setActiveWorkspace = ({ workspace }) => { | ||
196 | // Indicate that we are switching to another workspace | ||
197 | this.isSwitchingWorkspace = true; | ||
198 | this.nextWorkspace = workspace; | ||
199 | // Delay switching to next workspace so that the services loading does not drag down UI | ||
200 | setTimeout(() => { | ||
201 | this.activeWorkspace = workspace; | ||
202 | this._updateSettings({ lastActiveWorkspace: workspace.id }); | ||
203 | }, 100); | ||
204 | // Indicate that we are done switching to the next workspace | ||
205 | setTimeout(() => { | ||
206 | this.isSwitchingWorkspace = false; | ||
207 | this.nextWorkspace = null; | ||
208 | }, 1000); | ||
209 | }; | ||
210 | |||
211 | @action _deactivateActiveWorkspace = () => { | ||
212 | // Indicate that we are switching to default workspace | ||
213 | this.isSwitchingWorkspace = true; | ||
214 | this.nextWorkspace = null; | ||
215 | this._updateSettings({ lastActiveWorkspace: null }); | ||
216 | // Delay switching to next workspace so that the services loading does not drag down UI | ||
217 | setTimeout(() => { | ||
218 | this.activeWorkspace = null; | ||
219 | }, 100); | ||
220 | // Indicate that we are done switching to the default workspace | ||
221 | setTimeout(() => { this.isSwitchingWorkspace = false; }, 1000); | ||
222 | }; | ||
223 | |||
224 | @action _toggleWorkspaceDrawer = () => { | ||
225 | this.isWorkspaceDrawerOpen = !this.isWorkspaceDrawerOpen; | ||
226 | }; | ||
227 | |||
228 | @action _openWorkspaceSettings = () => { | ||
229 | this.actions.ui.openSettings({ path: 'workspaces' }); | ||
230 | }; | ||
231 | |||
232 | // Reactions | ||
233 | |||
234 | _setFeatureEnabledReaction = () => { | ||
235 | const { isWorkspaceEnabled } = this.stores.features.features; | ||
236 | this.isFeatureEnabled = isWorkspaceEnabled; | ||
237 | }; | ||
238 | |||
239 | _setIsPremiumFeatureReaction = () => { | ||
240 | const { features, user } = this.stores; | ||
241 | const { isPremium } = user.data; | ||
242 | const { isWorkspacePremiumFeature } = features.features; | ||
243 | this.isPremiumFeature = isWorkspacePremiumFeature; | ||
244 | this.isPremiumUpgradeRequired = isWorkspacePremiumFeature && !isPremium; | ||
245 | }; | ||
246 | |||
247 | _setWorkspaceBeingEditedReaction = () => { | ||
248 | const { pathname } = this.stores.router.location; | ||
249 | const match = matchRoute('/settings/workspaces/edit/:id', pathname); | ||
250 | if (match) { | ||
251 | this.workspaceBeingEdited = this._getWorkspaceById(match.id); | ||
252 | } | ||
253 | }; | ||
254 | |||
255 | _setActiveServiceOnWorkspaceSwitchReaction = () => { | ||
256 | if (!this.isFeatureActive) return; | ||
257 | if (this.activeWorkspace) { | ||
258 | const services = this.stores.services.allDisplayed; | ||
259 | const activeService = services.find(s => s.isActive); | ||
260 | const workspaceServices = this.getWorkspaceServices(this.activeWorkspace); | ||
261 | if (workspaceServices.length <= 0) return; | ||
262 | const isActiveServiceInWorkspace = workspaceServices.includes(activeService); | ||
263 | if (!isActiveServiceInWorkspace) { | ||
264 | this.actions.service.setActive({ serviceId: workspaceServices[0].id }); | ||
265 | } | ||
266 | } | ||
267 | }; | ||
268 | |||
269 | _activateLastUsedWorkspaceReaction = () => { | ||
270 | if (!this.activeWorkspace && this.userHasWorkspaces) { | ||
271 | const { lastActiveWorkspace } = this.settings; | ||
272 | if (lastActiveWorkspace) { | ||
273 | const workspace = this._getWorkspaceById(lastActiveWorkspace); | ||
274 | if (workspace) this._setActiveWorkspace({ workspace }); | ||
275 | } | ||
276 | } | ||
277 | }; | ||
278 | |||
279 | _openDrawerWithSettingsReaction = () => { | ||
280 | const { router } = this.stores; | ||
281 | const isWorkspaceSettingsRoute = router.location.pathname.includes(WORKSPACES_ROUTES.ROOT); | ||
282 | const isSwitchingToSettingsRoute = !this.isSettingsRouteActive && isWorkspaceSettingsRoute; | ||
283 | const isLeavingSettingsRoute = !isWorkspaceSettingsRoute && this.isSettingsRouteActive; | ||
284 | |||
285 | if (isSwitchingToSettingsRoute) { | ||
286 | this.isSettingsRouteActive = true; | ||
287 | this._wasDrawerOpenBeforeSettingsRoute = this.isWorkspaceDrawerOpen; | ||
288 | if (!this._wasDrawerOpenBeforeSettingsRoute) { | ||
289 | workspaceActions.toggleWorkspaceDrawer(); | ||
290 | } | ||
291 | } else if (isLeavingSettingsRoute) { | ||
292 | this.isSettingsRouteActive = false; | ||
293 | if (!this._wasDrawerOpenBeforeSettingsRoute && this.isWorkspaceDrawerOpen) { | ||
294 | workspaceActions.toggleWorkspaceDrawer(); | ||
295 | } | ||
296 | } | ||
297 | }; | ||
298 | |||
299 | _cleanupInvalidServiceReferences = () => { | ||
300 | const { services } = this.stores; | ||
301 | let invalidServiceReferencesExist = false; | ||
302 | this.workspaces.forEach((workspace) => { | ||
303 | workspace.services.forEach((serviceId) => { | ||
304 | if (!services.one(serviceId)) { | ||
305 | invalidServiceReferencesExist = true; | ||
306 | } | ||
307 | }); | ||
308 | }); | ||
309 | if (invalidServiceReferencesExist) { | ||
310 | getUserWorkspacesRequest.execute(); | ||
311 | } | ||
312 | }; | ||
313 | |||
314 | _stopPremiumActionsAndReactions = () => { | ||
315 | if (!this.isUserAllowedToUseFeature) { | ||
316 | this._stopActions(this._premiumUserActions); | ||
317 | this._stopReactions(this._premiumUserReactions); | ||
318 | } else { | ||
319 | this._startActions(this._premiumUserActions); | ||
320 | this._startReactions(this._premiumUserReactions); | ||
321 | } | ||
322 | } | ||
323 | } | ||