diff options
Diffstat (limited to 'src/features')
20 files changed, 1449 insertions, 4 deletions
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 abc8274cf..67f0fc5e6 100644 --- a/src/features/delayApp/index.js +++ b/src/features/delayApp/index.js | |||
@@ -55,7 +55,7 @@ export default function init(stores) { | |||
55 | 55 | ||
56 | setVisibility(true); | 56 | setVisibility(true); |
57 | gaPage('/delayApp'); | 57 | gaPage('/delayApp'); |
58 | gaEvent('delayApp', 'show', 'Delay App Feature'); | 58 | gaEvent('DelayApp', 'show', 'Delay App Feature'); |
59 | 59 | ||
60 | timeLastDelay = moment(); | 60 | timeLastDelay = moment(); |
61 | shownAfterLaunch = true; | 61 | shownAfterLaunch = true; |
diff --git a/src/features/spellchecker/index.js b/src/features/spellchecker/index.js index 94883ad17..79a2172b4 100644 --- a/src/features/spellchecker/index.js +++ b/src/features/spellchecker/index.js | |||
@@ -14,8 +14,6 @@ export default function init(stores) { | |||
14 | autorun(() => { | 14 | autorun(() => { |
15 | const { isSpellcheckerPremiumFeature } = stores.features.features; | 15 | const { isSpellcheckerPremiumFeature } = stores.features.features; |
16 | 16 | ||
17 | console.log('isSpellcheckerPremiumFeature', isSpellcheckerPremiumFeature); | ||
18 | |||
19 | config.isPremium = isSpellcheckerPremiumFeature !== undefined ? isSpellcheckerPremiumFeature : DEFAULT_FEATURES_CONFIG.isSpellcheckerPremiumFeature; | 17 | config.isPremium = isSpellcheckerPremiumFeature !== undefined ? isSpellcheckerPremiumFeature : DEFAULT_FEATURES_CONFIG.isSpellcheckerPremiumFeature; |
20 | 18 | ||
21 | if (!stores.user.data.isPremium && config.isPremium && stores.settings.app.enableSpellchecking) { | 19 | if (!stores.user.data.isPremium && config.isPremium && stores.settings.app.enableSpellchecking) { |
diff --git a/src/features/utils/FeatureStore.js b/src/features/utils/FeatureStore.js new file mode 100644 index 000000000..66b66a104 --- /dev/null +++ b/src/features/utils/FeatureStore.js | |||
@@ -0,0 +1,21 @@ | |||
1 | import Reaction from '../../stores/lib/Reaction'; | ||
2 | |||
3 | export class FeatureStore { | ||
4 | _actions = null; | ||
5 | |||
6 | _reactions = null; | ||
7 | |||
8 | _listenToActions(actions) { | ||
9 | if (this._actions) this._actions.forEach(a => a[0].off(a[1])); | ||
10 | this._actions = []; | ||
11 | actions.forEach(a => this._actions.push(a)); | ||
12 | this._actions.forEach(a => a[0].listen(a[1])); | ||
13 | } | ||
14 | |||
15 | _startReactions(reactions) { | ||
16 | if (this._reactions) this._reactions.forEach(r => r.stop()); | ||
17 | this._reactions = []; | ||
18 | reactions.forEach(r => this._reactions.push(new Reaction(r))); | ||
19 | this._reactions.forEach(r => r.start()); | ||
20 | } | ||
21 | } | ||
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..0be2d528f --- /dev/null +++ b/src/features/workspaces/components/CreateWorkspaceForm.js | |||
@@ -0,0 +1,99 @@ | |||
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 } 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 | /> | ||
86 | <Button | ||
87 | className={classes.submitButton} | ||
88 | type="submit" | ||
89 | label={intl.formatMessage(messages.submitButton)} | ||
90 | onClick={this.submitForm.bind(this, form)} | ||
91 | busy={isSubmitting} | ||
92 | buttonType={isSubmitting ? 'secondary' : 'primary'} | ||
93 | /> | ||
94 | </div> | ||
95 | ); | ||
96 | } | ||
97 | } | ||
98 | |||
99 | 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..e4bf44248 --- /dev/null +++ b/src/features/workspaces/components/EditWorkspaceForm.js | |||
@@ -0,0 +1,189 @@ | |||
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 { 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 ServiceListItem from './ServiceListItem'; | ||
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 | }); | ||
40 | |||
41 | const styles = () => ({ | ||
42 | nameInput: { | ||
43 | height: 'auto', | ||
44 | }, | ||
45 | serviceList: { | ||
46 | height: 'auto', | ||
47 | }, | ||
48 | }); | ||
49 | |||
50 | @injectSheet(styles) @observer | ||
51 | class EditWorkspaceForm extends Component { | ||
52 | static contextTypes = { | ||
53 | intl: intlShape, | ||
54 | }; | ||
55 | |||
56 | static propTypes = { | ||
57 | classes: PropTypes.object.isRequired, | ||
58 | onDelete: PropTypes.func.isRequired, | ||
59 | onSave: PropTypes.func.isRequired, | ||
60 | services: PropTypes.arrayOf(PropTypes.instanceOf(Service)).isRequired, | ||
61 | workspace: PropTypes.instanceOf(Workspace).isRequired, | ||
62 | updateWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, | ||
63 | deleteWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, | ||
64 | }; | ||
65 | |||
66 | form = this.prepareWorkspaceForm(this.props.workspace); | ||
67 | |||
68 | componentWillReceiveProps(nextProps) { | ||
69 | const { workspace } = this.props; | ||
70 | if (workspace.id !== nextProps.workspace.id) { | ||
71 | this.form = this.prepareWorkspaceForm(nextProps.workspace); | ||
72 | } | ||
73 | } | ||
74 | |||
75 | prepareWorkspaceForm(workspace) { | ||
76 | const { intl } = this.context; | ||
77 | return new Form({ | ||
78 | fields: { | ||
79 | name: { | ||
80 | label: intl.formatMessage(messages.name), | ||
81 | placeholder: intl.formatMessage(messages.name), | ||
82 | value: workspace.name, | ||
83 | validators: [required], | ||
84 | }, | ||
85 | services: { | ||
86 | value: workspace.services.slice(), | ||
87 | }, | ||
88 | }, | ||
89 | }); | ||
90 | } | ||
91 | |||
92 | save(form) { | ||
93 | form.submit({ | ||
94 | onSuccess: async (f) => { | ||
95 | const { onSave } = this.props; | ||
96 | const values = f.values(); | ||
97 | onSave(values); | ||
98 | gaEvent(GA_CATEGORY_WORKSPACES, 'save'); | ||
99 | }, | ||
100 | onError: async () => {}, | ||
101 | }); | ||
102 | } | ||
103 | |||
104 | delete() { | ||
105 | const { onDelete } = this.props; | ||
106 | onDelete(); | ||
107 | gaEvent(GA_CATEGORY_WORKSPACES, 'delete'); | ||
108 | } | ||
109 | |||
110 | toggleService(service) { | ||
111 | const servicesField = this.form.$('services'); | ||
112 | const serviceIds = servicesField.value; | ||
113 | if (serviceIds.includes(service.id)) { | ||
114 | serviceIds.splice(serviceIds.indexOf(service.id), 1); | ||
115 | } else { | ||
116 | serviceIds.push(service.id); | ||
117 | } | ||
118 | servicesField.set(serviceIds); | ||
119 | } | ||
120 | |||
121 | render() { | ||
122 | const { intl } = this.context; | ||
123 | const { | ||
124 | classes, | ||
125 | workspace, | ||
126 | services, | ||
127 | deleteWorkspaceRequest, | ||
128 | updateWorkspaceRequest, | ||
129 | } = this.props; | ||
130 | const { form } = this; | ||
131 | const workspaceServices = form.$('services').value; | ||
132 | const isDeleting = deleteWorkspaceRequest.isExecuting; | ||
133 | const isSaving = updateWorkspaceRequest.isExecuting; | ||
134 | return ( | ||
135 | <div className="settings__main"> | ||
136 | <div className="settings__header"> | ||
137 | <span className="settings__header-item"> | ||
138 | <Link to="/settings/workspaces"> | ||
139 | {intl.formatMessage(messages.yourWorkspaces)} | ||
140 | </Link> | ||
141 | </span> | ||
142 | <span className="separator" /> | ||
143 | <span className="settings__header-item"> | ||
144 | {workspace.name} | ||
145 | </span> | ||
146 | </div> | ||
147 | <div className="settings__body"> | ||
148 | <div className={classes.nameInput}> | ||
149 | <Input {...form.$('name').bind()} /> | ||
150 | </div> | ||
151 | <h2>{intl.formatMessage(messages.servicesInWorkspaceHeadline)}</h2> | ||
152 | <div className={classes.serviceList}> | ||
153 | {services.map(s => ( | ||
154 | <ServiceListItem | ||
155 | key={s.id} | ||
156 | service={s} | ||
157 | isInWorkspace={workspaceServices.includes(s.id)} | ||
158 | onToggle={() => this.toggleService(s)} | ||
159 | /> | ||
160 | ))} | ||
161 | </div> | ||
162 | </div> | ||
163 | <div className="settings__controls"> | ||
164 | {/* ===== Delete Button ===== */} | ||
165 | <Button | ||
166 | label={intl.formatMessage(messages.buttonDelete)} | ||
167 | loaded={false} | ||
168 | busy={isDeleting} | ||
169 | buttonType={isDeleting ? 'secondary' : 'danger'} | ||
170 | className="settings__delete-button" | ||
171 | disabled={isDeleting} | ||
172 | onClick={this.delete.bind(this)} | ||
173 | /> | ||
174 | {/* ===== Save Button ===== */} | ||
175 | <Button | ||
176 | type="submit" | ||
177 | label={intl.formatMessage(messages.buttonSave)} | ||
178 | busy={isSaving} | ||
179 | buttonType={isSaving ? 'secondary' : 'primary'} | ||
180 | onClick={this.save.bind(this, form)} | ||
181 | disabled={isSaving} | ||
182 | /> | ||
183 | </div> | ||
184 | </div> | ||
185 | ); | ||
186 | } | ||
187 | } | ||
188 | |||
189 | export default EditWorkspaceForm; | ||
diff --git a/src/features/workspaces/components/ServiceListItem.js b/src/features/workspaces/components/ServiceListItem.js new file mode 100644 index 000000000..146cc5a36 --- /dev/null +++ b/src/features/workspaces/components/ServiceListItem.js | |||
@@ -0,0 +1,48 @@ | |||
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 { Toggle } from '@meetfranz/forms'; | ||
6 | |||
7 | import Service from '../../../models/Service'; | ||
8 | |||
9 | const styles = () => ({ | ||
10 | service: { | ||
11 | height: 'auto', | ||
12 | display: 'flex', | ||
13 | }, | ||
14 | name: { | ||
15 | marginTop: '4px', | ||
16 | }, | ||
17 | }); | ||
18 | |||
19 | @injectSheet(styles) @observer | ||
20 | class ServiceListItem extends Component { | ||
21 | static propTypes = { | ||
22 | classes: PropTypes.object.isRequired, | ||
23 | isInWorkspace: PropTypes.bool.isRequired, | ||
24 | onToggle: PropTypes.func.isRequired, | ||
25 | service: PropTypes.instanceOf(Service).isRequired, | ||
26 | }; | ||
27 | |||
28 | render() { | ||
29 | const { | ||
30 | classes, | ||
31 | isInWorkspace, | ||
32 | onToggle, | ||
33 | service, | ||
34 | } = this.props; | ||
35 | |||
36 | return ( | ||
37 | <div className={classes.service}> | ||
38 | <Toggle | ||
39 | checked={isInWorkspace} | ||
40 | onChange={onToggle} | ||
41 | label={service.name} | ||
42 | /> | ||
43 | </div> | ||
44 | ); | ||
45 | } | ||
46 | } | ||
47 | |||
48 | export default ServiceListItem; | ||
diff --git a/src/features/workspaces/components/WorkspaceDrawer.js b/src/features/workspaces/components/WorkspaceDrawer.js new file mode 100644 index 000000000..6eacafa68 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceDrawer.js | |||
@@ -0,0 +1,167 @@ | |||
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 } 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 | addWorkspaceTooltip: { | ||
25 | id: 'workspaceDrawer.addWorkspaceTooltip', | ||
26 | defaultMessage: '!!!Add workspace', | ||
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 | }); | ||
37 | |||
38 | const styles = theme => ({ | ||
39 | drawer: { | ||
40 | backgroundColor: theme.workspaceDrawerBackground, | ||
41 | width: `${theme.workspaceDrawerWidth}px`, | ||
42 | }, | ||
43 | headline: { | ||
44 | fontSize: '24px', | ||
45 | marginTop: '38px', | ||
46 | marginBottom: '25px', | ||
47 | marginLeft: `${theme.workspaceDrawerPadding}px`, | ||
48 | }, | ||
49 | addWorkspaceButton: { | ||
50 | float: 'right', | ||
51 | marginRight: `${theme.workspaceDrawerPadding}px`, | ||
52 | marginTop: '2px', | ||
53 | }, | ||
54 | addWorkspaceButtonIcon: { | ||
55 | fill: theme.workspaceDrawerAddButtonColor, | ||
56 | '&:hover': { | ||
57 | fill: theme.workspaceDrawerAddButtonHoverColor, | ||
58 | }, | ||
59 | }, | ||
60 | workspaces: { | ||
61 | height: 'auto', | ||
62 | }, | ||
63 | premiumAnnouncement: { | ||
64 | padding: '20px', | ||
65 | paddingTop: '0', | ||
66 | height: 'auto', | ||
67 | }, | ||
68 | premiumCtaButton: { | ||
69 | marginTop: '20px', | ||
70 | width: '100%', | ||
71 | color: 'white !important', | ||
72 | }, | ||
73 | }); | ||
74 | |||
75 | @injectSheet(styles) @observer | ||
76 | class WorkspaceDrawer extends Component { | ||
77 | static propTypes = { | ||
78 | classes: PropTypes.object.isRequired, | ||
79 | getServicesForWorkspace: PropTypes.func.isRequired, | ||
80 | }; | ||
81 | |||
82 | static contextTypes = { | ||
83 | intl: intlShape, | ||
84 | }; | ||
85 | |||
86 | componentDidMount() { | ||
87 | ReactTooltip.rebuild(); | ||
88 | } | ||
89 | |||
90 | render() { | ||
91 | const { | ||
92 | classes, | ||
93 | getServicesForWorkspace, | ||
94 | } = this.props; | ||
95 | const { intl } = this.context; | ||
96 | const { | ||
97 | activeWorkspace, | ||
98 | isSwitchingWorkspace, | ||
99 | nextWorkspace, | ||
100 | workspaces, | ||
101 | } = workspaceStore; | ||
102 | const actualWorkspace = isSwitchingWorkspace ? nextWorkspace : activeWorkspace; | ||
103 | return ( | ||
104 | <div className={classes.drawer}> | ||
105 | <H1 className={classes.headline}> | ||
106 | {intl.formatMessage(messages.headline)} | ||
107 | <span | ||
108 | className={classes.addWorkspaceButton} | ||
109 | onClick={() => { | ||
110 | workspaceActions.openWorkspaceSettings(); | ||
111 | gaEvent(GA_CATEGORY_WORKSPACES, 'add', 'drawerHeadline'); | ||
112 | }} | ||
113 | data-tip={`${intl.formatMessage(messages.addWorkspaceTooltip)}`} | ||
114 | > | ||
115 | <Icon | ||
116 | icon="mdiPlusBox" | ||
117 | size={1.5} | ||
118 | className={classes.addWorkspaceButtonIcon} | ||
119 | /> | ||
120 | </span> | ||
121 | </H1> | ||
122 | {workspaceStore.isPremiumUpgradeRequired ? ( | ||
123 | <div className={classes.premiumAnnouncement}> | ||
124 | <FormattedHTMLMessage {...messages.workspaceFeatureInfo} /> | ||
125 | <Button | ||
126 | className={classes.premiumCtaButton} | ||
127 | buttonType="primary" | ||
128 | label={intl.formatMessage(messages.premiumCtaButtonLabel)} | ||
129 | icon="mdiPlusBox" | ||
130 | onClick={() => { | ||
131 | workspaceActions.openWorkspaceSettings(); | ||
132 | gaEvent(GA_CATEGORY_WORKSPACES, 'add', 'drawerPremiumCta'); | ||
133 | }} | ||
134 | /> | ||
135 | </div> | ||
136 | ) : ( | ||
137 | <div className={classes.workspaces}> | ||
138 | <WorkspaceDrawerItem | ||
139 | name={intl.formatMessage(messages.allServices)} | ||
140 | onClick={() => { | ||
141 | workspaceActions.deactivate(); | ||
142 | gaEvent(GA_CATEGORY_WORKSPACES, 'switch', 'drawer'); | ||
143 | }} | ||
144 | services={getServicesForWorkspace(null)} | ||
145 | isActive={actualWorkspace == null} | ||
146 | /> | ||
147 | {workspaces.map(workspace => ( | ||
148 | <WorkspaceDrawerItem | ||
149 | key={workspace.id} | ||
150 | name={workspace.name} | ||
151 | isActive={actualWorkspace === workspace} | ||
152 | onClick={() => { | ||
153 | workspaceActions.activate({ workspace }); | ||
154 | gaEvent(GA_CATEGORY_WORKSPACES, 'switch', 'drawer'); | ||
155 | }} | ||
156 | services={getServicesForWorkspace(workspace)} | ||
157 | /> | ||
158 | ))} | ||
159 | </div> | ||
160 | )} | ||
161 | <ReactTooltip place="right" type="dark" effect="solid" /> | ||
162 | </div> | ||
163 | ); | ||
164 | } | ||
165 | } | ||
166 | |||
167 | 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..1e28ebea6 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceDrawerItem.js | |||
@@ -0,0 +1,101 @@ | |||
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 { defineMessages, intlShape } from 'react-intl'; | ||
7 | |||
8 | const messages = defineMessages({ | ||
9 | noServicesAddedYet: { | ||
10 | id: 'workspaceDrawer.item.noServicesAddedYet', | ||
11 | defaultMessage: '!!!No services added yet', | ||
12 | }, | ||
13 | }); | ||
14 | |||
15 | const styles = theme => ({ | ||
16 | item: { | ||
17 | height: '67px', | ||
18 | padding: `15px ${theme.workspaceDrawerPadding}px`, | ||
19 | borderBottom: `1px solid ${theme.workspaceDrawerItemBorder}`, | ||
20 | '&:first-child': { | ||
21 | borderTop: `1px solid ${theme.workspaceDrawerItemBorder}`, | ||
22 | }, | ||
23 | }, | ||
24 | isActiveItem: { | ||
25 | backgroundColor: theme.workspaceDrawerItemActiveBackground, | ||
26 | }, | ||
27 | name: { | ||
28 | marginTop: '4px', | ||
29 | color: theme.workspaceDrawerItemNameColor, | ||
30 | }, | ||
31 | activeName: { | ||
32 | color: theme.workspaceDrawerItemNameActiveColor, | ||
33 | }, | ||
34 | services: { | ||
35 | display: 'block', | ||
36 | fontSize: '11px', | ||
37 | marginTop: '5px', | ||
38 | color: theme.workspaceDrawerServicesColor, | ||
39 | whiteSpace: 'nowrap', | ||
40 | textOverflow: 'ellipsis', | ||
41 | overflow: 'hidden', | ||
42 | lineHeight: '15px', | ||
43 | }, | ||
44 | activeServices: { | ||
45 | color: theme.workspaceDrawerServicesActiveColor, | ||
46 | }, | ||
47 | }); | ||
48 | |||
49 | @injectSheet(styles) @observer | ||
50 | class WorkspaceDrawerItem extends Component { | ||
51 | static propTypes = { | ||
52 | classes: PropTypes.object.isRequired, | ||
53 | isActive: PropTypes.bool.isRequired, | ||
54 | name: PropTypes.string.isRequired, | ||
55 | onClick: PropTypes.func.isRequired, | ||
56 | services: PropTypes.arrayOf(PropTypes.string).isRequired, | ||
57 | }; | ||
58 | |||
59 | static contextTypes = { | ||
60 | intl: intlShape, | ||
61 | }; | ||
62 | |||
63 | render() { | ||
64 | const { | ||
65 | classes, | ||
66 | isActive, | ||
67 | name, | ||
68 | onClick, | ||
69 | services, | ||
70 | } = this.props; | ||
71 | const { intl } = this.context; | ||
72 | return ( | ||
73 | <div | ||
74 | className={classnames([ | ||
75 | classes.item, | ||
76 | isActive ? classes.isActiveItem : null, | ||
77 | ])} | ||
78 | onClick={onClick} | ||
79 | > | ||
80 | <span | ||
81 | className={classnames([ | ||
82 | classes.name, | ||
83 | isActive ? classes.activeName : null, | ||
84 | ])} | ||
85 | > | ||
86 | {name} | ||
87 | </span> | ||
88 | <span | ||
89 | className={classnames([ | ||
90 | classes.services, | ||
91 | isActive ? classes.activeServices : null, | ||
92 | ])} | ||
93 | > | ||
94 | {services.length ? services.join(', ') : intl.formatMessage(messages.noServicesAddedYet)} | ||
95 | </span> | ||
96 | </div> | ||
97 | ); | ||
98 | } | ||
99 | } | ||
100 | |||
101 | 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..b2c2a4830 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceItem.js | |||
@@ -0,0 +1,42 @@ | |||
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 classnames from 'classnames'; | ||
6 | import Workspace from '../models/Workspace'; | ||
7 | |||
8 | // const messages = defineMessages({}); | ||
9 | |||
10 | @observer | ||
11 | class WorkspaceItem extends Component { | ||
12 | static propTypes = { | ||
13 | workspace: PropTypes.instanceOf(Workspace).isRequired, | ||
14 | onItemClick: PropTypes.func.isRequired, | ||
15 | }; | ||
16 | |||
17 | static contextTypes = { | ||
18 | intl: intlShape, | ||
19 | }; | ||
20 | |||
21 | render() { | ||
22 | const { workspace, onItemClick } = this.props; | ||
23 | // const { intl } = this.context; | ||
24 | |||
25 | return ( | ||
26 | <tr | ||
27 | className={classnames({ | ||
28 | 'workspace-table__row': true, | ||
29 | })} | ||
30 | > | ||
31 | <td | ||
32 | className="workspace-table__column-name" | ||
33 | onClick={() => onItemClick(workspace)} | ||
34 | > | ||
35 | {workspace.name} | ||
36 | </td> | ||
37 | </tr> | ||
38 | ); | ||
39 | } | ||
40 | } | ||
41 | |||
42 | export default WorkspaceItem; | ||
diff --git a/src/features/workspaces/components/WorkspaceSwitchingIndicator.js b/src/features/workspaces/components/WorkspaceSwitchingIndicator.js new file mode 100644 index 000000000..8aba5bbd9 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceSwitchingIndicator.js | |||
@@ -0,0 +1,87 @@ | |||
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 { defineMessages, intlShape } from 'react-intl'; | ||
7 | |||
8 | import LoaderComponent from '../../../components/ui/Loader'; | ||
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.workspaceDrawerWidth}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: '40px', | ||
43 | marginRight: '5px', | ||
44 | }, | ||
45 | message: { | ||
46 | fontSize: 16, | ||
47 | whiteSpace: 'nowrap', | ||
48 | }, | ||
49 | }); | ||
50 | |||
51 | @injectSheet(styles) @observer | ||
52 | class WorkspaceSwitchingIndicator extends Component { | ||
53 | static propTypes = { | ||
54 | classes: PropTypes.object.isRequired, | ||
55 | }; | ||
56 | |||
57 | static contextTypes = { | ||
58 | intl: intlShape, | ||
59 | }; | ||
60 | |||
61 | render() { | ||
62 | const { classes } = this.props; | ||
63 | const { intl } = this.context; | ||
64 | const { isSwitchingWorkspace, isWorkspaceDrawerOpen, nextWorkspace } = workspaceStore; | ||
65 | if (!isSwitchingWorkspace) return null; | ||
66 | const nextWorkspaceName = nextWorkspace ? nextWorkspace.name : 'All services'; | ||
67 | return ( | ||
68 | <div | ||
69 | className={classnames([ | ||
70 | classes.wrapper, | ||
71 | isWorkspaceDrawerOpen ? classes.wrapperWhenDrawerIsOpen : null, | ||
72 | ])} | ||
73 | > | ||
74 | <div className={classes.component}> | ||
75 | <div className={classes.spinner}> | ||
76 | <LoaderComponent /> | ||
77 | </div> | ||
78 | <p className={classes.message}> | ||
79 | {`${intl.formatMessage(messages.switchingTo)} ${nextWorkspaceName}`} | ||
80 | </p> | ||
81 | </div> | ||
82 | </div> | ||
83 | ); | ||
84 | } | ||
85 | } | ||
86 | |||
87 | 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..b141dc960 --- /dev/null +++ b/src/features/workspaces/components/WorkspacesDashboard.js | |||
@@ -0,0 +1,187 @@ | |||
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 = () => ({ | ||
52 | createForm: { | ||
53 | height: 'auto', | ||
54 | }, | ||
55 | appear: { | ||
56 | height: 'auto', | ||
57 | }, | ||
58 | premiumAnnouncement: { | ||
59 | padding: '20px', | ||
60 | backgroundColor: '#3498db', | ||
61 | marginLeft: '-20px', | ||
62 | marginBottom: '20px', | ||
63 | height: 'auto', | ||
64 | }, | ||
65 | }); | ||
66 | |||
67 | @injectSheet(styles) @observer | ||
68 | class WorkspacesDashboard extends Component { | ||
69 | static propTypes = { | ||
70 | classes: PropTypes.object.isRequired, | ||
71 | getUserWorkspacesRequest: PropTypes.instanceOf(Request).isRequired, | ||
72 | createWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, | ||
73 | deleteWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, | ||
74 | updateWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, | ||
75 | onCreateWorkspaceSubmit: PropTypes.func.isRequired, | ||
76 | onWorkspaceClick: PropTypes.func.isRequired, | ||
77 | workspaces: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
78 | }; | ||
79 | |||
80 | static contextTypes = { | ||
81 | intl: intlShape, | ||
82 | }; | ||
83 | |||
84 | render() { | ||
85 | const { | ||
86 | classes, | ||
87 | getUserWorkspacesRequest, | ||
88 | createWorkspaceRequest, | ||
89 | deleteWorkspaceRequest, | ||
90 | updateWorkspaceRequest, | ||
91 | onCreateWorkspaceSubmit, | ||
92 | onWorkspaceClick, | ||
93 | workspaces, | ||
94 | } = this.props; | ||
95 | const { intl } = this.context; | ||
96 | return ( | ||
97 | <div className="settings__main"> | ||
98 | <div className="settings__header"> | ||
99 | <h1>{intl.formatMessage(messages.headline)}</h1> | ||
100 | </div> | ||
101 | <div className="settings__body"> | ||
102 | |||
103 | {/* ===== Workspace updated info ===== */} | ||
104 | {updateWorkspaceRequest.wasExecuted && updateWorkspaceRequest.result && ( | ||
105 | <Appear className={classes.appear}> | ||
106 | <Infobox | ||
107 | type="success" | ||
108 | icon="mdiCheckboxMarkedCircleOutline" | ||
109 | dismissable | ||
110 | onUnmount={updateWorkspaceRequest.reset} | ||
111 | > | ||
112 | {intl.formatMessage(messages.updatedInfo)} | ||
113 | </Infobox> | ||
114 | </Appear> | ||
115 | )} | ||
116 | |||
117 | {/* ===== Workspace deleted info ===== */} | ||
118 | {deleteWorkspaceRequest.wasExecuted && deleteWorkspaceRequest.result && ( | ||
119 | <Appear className={classes.appear}> | ||
120 | <Infobox | ||
121 | type="success" | ||
122 | icon="mdiCheckboxMarkedCircleOutline" | ||
123 | dismissable | ||
124 | onUnmount={deleteWorkspaceRequest.reset} | ||
125 | > | ||
126 | {intl.formatMessage(messages.deletedInfo)} | ||
127 | </Infobox> | ||
128 | </Appear> | ||
129 | )} | ||
130 | |||
131 | {workspaceStore.isPremiumUpgradeRequired && ( | ||
132 | <div className={classes.premiumAnnouncement}> | ||
133 | <h2>{intl.formatMessage(messages.workspaceFeatureHeadline)}</h2> | ||
134 | <p>{intl.formatMessage(messages.workspaceFeatureInfo)}</p> | ||
135 | </div> | ||
136 | )} | ||
137 | |||
138 | <PremiumFeatureContainer | ||
139 | condition={workspaceStore.isPremiumFeature} | ||
140 | gaEventInfo={{ category: 'User', event: 'upgrade', label: 'workspaces' }} | ||
141 | > | ||
142 | {/* ===== Create workspace form ===== */} | ||
143 | <div className={classes.createForm}> | ||
144 | <CreateWorkspaceForm | ||
145 | isSubmitting={createWorkspaceRequest.isExecuting} | ||
146 | onSubmit={onCreateWorkspaceSubmit} | ||
147 | /> | ||
148 | </div> | ||
149 | </PremiumFeatureContainer> | ||
150 | {getUserWorkspacesRequest.isExecuting ? ( | ||
151 | <Loader /> | ||
152 | ) : ( | ||
153 | <Fragment> | ||
154 | {/* ===== Workspace could not be loaded error ===== */} | ||
155 | {getUserWorkspacesRequest.error ? ( | ||
156 | <Infobox | ||
157 | icon="alert" | ||
158 | type="danger" | ||
159 | ctaLabel={intl.formatMessage(messages.tryReloadWorkspaces)} | ||
160 | ctaLoading={getUserWorkspacesRequest.isExecuting} | ||
161 | ctaOnClick={getUserWorkspacesRequest.retry} | ||
162 | > | ||
163 | {intl.formatMessage(messages.workspacesRequestFailed)} | ||
164 | </Infobox> | ||
165 | ) : ( | ||
166 | <table className="workspace-table"> | ||
167 | {/* ===== Workspaces list ===== */} | ||
168 | <tbody> | ||
169 | {workspaces.map(workspace => ( | ||
170 | <WorkspaceItem | ||
171 | key={workspace.id} | ||
172 | workspace={workspace} | ||
173 | onItemClick={w => onWorkspaceClick(w)} | ||
174 | /> | ||
175 | ))} | ||
176 | </tbody> | ||
177 | </table> | ||
178 | )} | ||
179 | </Fragment> | ||
180 | )} | ||
181 | </div> | ||
182 | </div> | ||
183 | ); | ||
184 | } | ||
185 | } | ||
186 | |||
187 | 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..524a83e3c --- /dev/null +++ b/src/features/workspaces/index.js | |||
@@ -0,0 +1,36 @@ | |||
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, user } = stores; | ||
14 | |||
15 | // Toggle workspace feature | ||
16 | reaction( | ||
17 | () => ( | ||
18 | features.features.isWorkspaceEnabled && ( | ||
19 | !features.features.isWorkspacePremiumFeature || user.data.isPremium | ||
20 | ) | ||
21 | ), | ||
22 | (isEnabled) => { | ||
23 | if (isEnabled && !workspaceStore.isFeatureActive) { | ||
24 | debug('Initializing `workspaces` feature'); | ||
25 | workspaceStore.start(stores, actions); | ||
26 | } else if (workspaceStore.isFeatureActive) { | ||
27 | debug('Disabling `workspaces` feature'); | ||
28 | workspaceStore.stop(); | ||
29 | resetApiRequests(); | ||
30 | } | ||
31 | }, | ||
32 | { | ||
33 | fireImmediately: true, | ||
34 | }, | ||
35 | ); | ||
36 | } | ||
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..712945bdc --- /dev/null +++ b/src/features/workspaces/store.js | |||
@@ -0,0 +1,198 @@ | |||
1 | import { | ||
2 | computed, | ||
3 | observable, | ||
4 | action, | ||
5 | } from 'mobx'; | ||
6 | import { matchRoute } from '../../helpers/routing-helpers'; | ||
7 | import { workspaceActions } from './actions'; | ||
8 | import { FeatureStore } from '../utils/FeatureStore'; | ||
9 | import { | ||
10 | createWorkspaceRequest, | ||
11 | deleteWorkspaceRequest, | ||
12 | getUserWorkspacesRequest, | ||
13 | updateWorkspaceRequest, | ||
14 | } from './api'; | ||
15 | |||
16 | const debug = require('debug')('Franz:feature:workspaces:store'); | ||
17 | |||
18 | export default class WorkspacesStore extends FeatureStore { | ||
19 | @observable isFeatureEnabled = false; | ||
20 | |||
21 | @observable isPremiumFeature = true; | ||
22 | |||
23 | @observable isFeatureActive = false; | ||
24 | |||
25 | @observable activeWorkspace = null; | ||
26 | |||
27 | @observable nextWorkspace = null; | ||
28 | |||
29 | @observable workspaceBeingEdited = null; | ||
30 | |||
31 | @observable isSwitchingWorkspace = false; | ||
32 | |||
33 | @observable isWorkspaceDrawerOpen = false; | ||
34 | |||
35 | @computed get workspaces() { | ||
36 | if (!this.isFeatureActive) return []; | ||
37 | return getUserWorkspacesRequest.result || []; | ||
38 | } | ||
39 | |||
40 | @computed get isPremiumUpgradeRequired() { | ||
41 | return this.isFeatureEnabled && !this.isFeatureActive; | ||
42 | } | ||
43 | |||
44 | start(stores, actions) { | ||
45 | debug('WorkspacesStore::start'); | ||
46 | this.stores = stores; | ||
47 | this.actions = actions; | ||
48 | |||
49 | this._listenToActions([ | ||
50 | [workspaceActions.edit, this._edit], | ||
51 | [workspaceActions.create, this._create], | ||
52 | [workspaceActions.delete, this._delete], | ||
53 | [workspaceActions.update, this._update], | ||
54 | [workspaceActions.activate, this._setActiveWorkspace], | ||
55 | [workspaceActions.deactivate, this._deactivateActiveWorkspace], | ||
56 | [workspaceActions.toggleWorkspaceDrawer, this._toggleWorkspaceDrawer], | ||
57 | [workspaceActions.openWorkspaceSettings, this._openWorkspaceSettings], | ||
58 | ]); | ||
59 | |||
60 | this._startReactions([ | ||
61 | this._setWorkspaceBeingEditedReaction, | ||
62 | this._setActiveServiceOnWorkspaceSwitchReaction, | ||
63 | this._setFeatureEnabledReaction, | ||
64 | this._setIsPremiumFeatureReaction, | ||
65 | ]); | ||
66 | |||
67 | getUserWorkspacesRequest.execute(); | ||
68 | this.isFeatureActive = true; | ||
69 | } | ||
70 | |||
71 | stop() { | ||
72 | debug('WorkspacesStore::stop'); | ||
73 | this.isFeatureActive = false; | ||
74 | this.activeWorkspace = null; | ||
75 | this.nextWorkspace = null; | ||
76 | this.workspaceBeingEdited = null; | ||
77 | this.isSwitchingWorkspace = false; | ||
78 | this.isWorkspaceDrawerOpen = false; | ||
79 | } | ||
80 | |||
81 | filterServicesByActiveWorkspace = (services) => { | ||
82 | const { activeWorkspace, isFeatureActive } = this; | ||
83 | |||
84 | if (!isFeatureActive) return services; | ||
85 | if (activeWorkspace) { | ||
86 | return services.filter(s => ( | ||
87 | activeWorkspace.services.includes(s.id) | ||
88 | )); | ||
89 | } | ||
90 | return services; | ||
91 | }; | ||
92 | |||
93 | // ========== PRIVATE ========= // | ||
94 | |||
95 | _getWorkspaceById = id => this.workspaces.find(w => w.id === id); | ||
96 | |||
97 | // Actions | ||
98 | |||
99 | @action _edit = ({ workspace }) => { | ||
100 | this.stores.router.push(`/settings/workspaces/edit/${workspace.id}`); | ||
101 | }; | ||
102 | |||
103 | @action _create = async ({ name }) => { | ||
104 | try { | ||
105 | const workspace = await createWorkspaceRequest.execute(name); | ||
106 | await getUserWorkspacesRequest.result.push(workspace); | ||
107 | this._edit({ workspace }); | ||
108 | } catch (error) { | ||
109 | throw error; | ||
110 | } | ||
111 | }; | ||
112 | |||
113 | @action _delete = async ({ workspace }) => { | ||
114 | try { | ||
115 | await deleteWorkspaceRequest.execute(workspace); | ||
116 | await getUserWorkspacesRequest.result.remove(workspace); | ||
117 | this.stores.router.push('/settings/workspaces'); | ||
118 | } catch (error) { | ||
119 | throw error; | ||
120 | } | ||
121 | }; | ||
122 | |||
123 | @action _update = async ({ workspace }) => { | ||
124 | try { | ||
125 | await updateWorkspaceRequest.execute(workspace); | ||
126 | // Path local result optimistically | ||
127 | const localWorkspace = this._getWorkspaceById(workspace.id); | ||
128 | Object.assign(localWorkspace, workspace); | ||
129 | this.stores.router.push('/settings/workspaces'); | ||
130 | } catch (error) { | ||
131 | throw error; | ||
132 | } | ||
133 | }; | ||
134 | |||
135 | @action _setActiveWorkspace = ({ workspace }) => { | ||
136 | // Indicate that we are switching to another workspace | ||
137 | this.isSwitchingWorkspace = true; | ||
138 | this.nextWorkspace = workspace; | ||
139 | // Delay switching to next workspace so that the services loading does not drag down UI | ||
140 | setTimeout(() => { this.activeWorkspace = workspace; }, 100); | ||
141 | // Indicate that we are done switching to the next workspace | ||
142 | setTimeout(() => { | ||
143 | this.isSwitchingWorkspace = false; | ||
144 | this.nextWorkspace = null; | ||
145 | }, 1000); | ||
146 | }; | ||
147 | |||
148 | @action _deactivateActiveWorkspace = () => { | ||
149 | // Indicate that we are switching to default workspace | ||
150 | this.isSwitchingWorkspace = true; | ||
151 | this.nextWorkspace = null; | ||
152 | // Delay switching to next workspace so that the services loading does not drag down UI | ||
153 | setTimeout(() => { this.activeWorkspace = null; }, 100); | ||
154 | // Indicate that we are done switching to the default workspace | ||
155 | setTimeout(() => { this.isSwitchingWorkspace = false; }, 1000); | ||
156 | }; | ||
157 | |||
158 | @action _toggleWorkspaceDrawer = () => { | ||
159 | this.isWorkspaceDrawerOpen = !this.isWorkspaceDrawerOpen; | ||
160 | }; | ||
161 | |||
162 | @action _openWorkspaceSettings = () => { | ||
163 | this.actions.ui.openSettings({ path: 'workspaces' }); | ||
164 | }; | ||
165 | |||
166 | // Reactions | ||
167 | |||
168 | _setFeatureEnabledReaction = () => { | ||
169 | const { isWorkspaceEnabled } = this.stores.features.features; | ||
170 | this.isFeatureEnabled = isWorkspaceEnabled; | ||
171 | }; | ||
172 | |||
173 | _setIsPremiumFeatureReaction = () => { | ||
174 | const { isWorkspacePremiumFeature } = this.stores.features.features; | ||
175 | this.isPremiumFeature = isWorkspacePremiumFeature; | ||
176 | }; | ||
177 | |||
178 | _setWorkspaceBeingEditedReaction = () => { | ||
179 | const { pathname } = this.stores.router.location; | ||
180 | const match = matchRoute('/settings/workspaces/edit/:id', pathname); | ||
181 | if (match) { | ||
182 | this.workspaceBeingEdited = this._getWorkspaceById(match.id); | ||
183 | } | ||
184 | }; | ||
185 | |||
186 | _setActiveServiceOnWorkspaceSwitchReaction = () => { | ||
187 | if (!this.isFeatureActive) return; | ||
188 | if (this.activeWorkspace) { | ||
189 | const services = this.stores.services.allDisplayed; | ||
190 | const activeService = services.find(s => s.isActive); | ||
191 | const workspaceServices = this.filterServicesByActiveWorkspace(services); | ||
192 | const isActiveServiceInWorkspace = workspaceServices.includes(activeService); | ||
193 | if (!isActiveServiceInWorkspace) { | ||
194 | this.actions.service.setActive({ serviceId: workspaceServices[0].id }); | ||
195 | } | ||
196 | } | ||
197 | }; | ||
198 | } | ||
diff --git a/src/features/workspaces/styles/workspaces-table.scss b/src/features/workspaces/styles/workspaces-table.scss new file mode 100644 index 000000000..6d0e7b4f5 --- /dev/null +++ b/src/features/workspaces/styles/workspaces-table.scss | |||
@@ -0,0 +1,53 @@ | |||
1 | @import '../../../styles/config'; | ||
2 | |||
3 | .theme__dark .workspace-table { | ||
4 | .workspace-table__column-info .mdi { color: $dark-theme-gray-lightest; } | ||
5 | |||
6 | .workspace-table__row { | ||
7 | border-bottom: 1px solid $dark-theme-gray-darker; | ||
8 | |||
9 | &:hover { background: $dark-theme-gray-darker; } | ||
10 | &.workspace-table__row--disabled { color: $dark-theme-gray; } | ||
11 | } | ||
12 | } | ||
13 | |||
14 | .workspace-table { | ||
15 | width: 100%; | ||
16 | |||
17 | .workspace-table__toggle { | ||
18 | width: 60px; | ||
19 | |||
20 | .franz-form__field { | ||
21 | margin-bottom: 0; | ||
22 | } | ||
23 | } | ||
24 | |||
25 | .workspace-table__column-action { | ||
26 | width: 40px | ||
27 | } | ||
28 | |||
29 | .workspace-table__column-info { | ||
30 | width: 40px; | ||
31 | |||
32 | .mdi { | ||
33 | color: $theme-gray-light; | ||
34 | display: block; | ||
35 | font-size: 18px; | ||
36 | } | ||
37 | } | ||
38 | |||
39 | .workspace-table__row { | ||
40 | border-bottom: 1px solid $theme-gray-lightest; | ||
41 | |||
42 | &:hover { | ||
43 | cursor: initial; | ||
44 | background: $theme-gray-lightest; | ||
45 | } | ||
46 | |||
47 | &.workspace-table__row--disabled { | ||
48 | color: $theme-gray-light; | ||
49 | } | ||
50 | } | ||
51 | |||
52 | td { padding: 10px; } | ||
53 | } | ||