From d9502c7516bc2d4ae467c6ea8a2e4816b0885f37 Mon Sep 17 00:00:00 2001 From: muhamedsalih-tw <104364298+muhamedsalih-tw@users.noreply.github.com> Date: Thu, 17 Nov 2022 05:45:39 +0530 Subject: Transfrom workspace components to ts (#775) --- src/@types/stores.types.ts | 4 +- src/components/auth/AuthLayout.tsx | 8 +- .../workspaces/components/CreateWorkspaceForm.js | 97 -------- .../workspaces/components/CreateWorkspaceForm.tsx | 97 ++++++++ .../workspaces/components/EditWorkspaceForm.js | 240 -------------------- .../workspaces/components/EditWorkspaceForm.tsx | 247 +++++++++++++++++++++ .../workspaces/components/WorkspaceDrawer.tsx | 7 +- .../workspaces/components/WorkspaceDrawerItem.js | 153 ------------- .../workspaces/components/WorkspaceDrawerItem.tsx | 153 +++++++++++++ .../workspaces/components/WorkspaceItem.tsx | 28 +-- .../components/WorkspaceServiceListItem.tsx | 5 +- .../workspaces/components/WorkspacesDashboard.js | 201 ----------------- .../workspaces/components/WorkspacesDashboard.tsx | 191 ++++++++++++++++ .../workspaces/containers/WorkspacesScreen.tsx | 13 +- src/stores/AppStore.ts | 2 +- src/stores/FeaturesStore.ts | 2 +- src/stores/GlobalErrorStore.ts | 27 +-- src/stores/RecipesStore.ts | 10 +- src/stores/RequestStore.ts | 3 + src/stores/ServicesStore.ts | 10 +- src/stores/UserStore.ts | 17 +- src/stores/lib/CachedRequest.js | 121 ---------- src/stores/lib/CachedRequest.ts | 126 +++++++++++ src/stores/lib/Request.js | 155 ------------- src/stores/lib/Request.ts | 159 +++++++++++++ 25 files changed, 1043 insertions(+), 1033 deletions(-) delete mode 100644 src/features/workspaces/components/CreateWorkspaceForm.js create mode 100644 src/features/workspaces/components/CreateWorkspaceForm.tsx delete mode 100644 src/features/workspaces/components/EditWorkspaceForm.js create mode 100644 src/features/workspaces/components/EditWorkspaceForm.tsx delete mode 100644 src/features/workspaces/components/WorkspaceDrawerItem.js create mode 100644 src/features/workspaces/components/WorkspaceDrawerItem.tsx delete mode 100644 src/features/workspaces/components/WorkspacesDashboard.js create mode 100644 src/features/workspaces/components/WorkspacesDashboard.tsx delete mode 100644 src/stores/lib/CachedRequest.js create mode 100644 src/stores/lib/CachedRequest.ts delete mode 100644 src/stores/lib/Request.js create mode 100644 src/stores/lib/Request.ts (limited to 'src') diff --git a/src/@types/stores.types.ts b/src/@types/stores.types.ts index bbb37cff9..edea41ea9 100644 --- a/src/@types/stores.types.ts +++ b/src/@types/stores.types.ts @@ -2,8 +2,8 @@ import Workspace from '../features/workspaces/models/Workspace'; import Recipe from '../models/Recipe'; import Service from '../models/Service'; import User from '../models/User'; -import { Request } from '../stores/lib/Request'; -import { CachedRequest } from '../stores/lib/CachedRequest'; +import Request from '../stores/lib/Request'; +import CachedRequest from '../stores/lib/CachedRequest'; import Reaction from '../stores/lib/Reaction'; // TODO: This file will be removed in the future when all stores are diff --git a/src/components/auth/AuthLayout.tsx b/src/components/auth/AuthLayout.tsx index 527c2bb74..eeb93b83b 100644 --- a/src/components/auth/AuthLayout.tsx +++ b/src/components/auth/AuthLayout.tsx @@ -8,7 +8,7 @@ import { observer } from 'mobx-react'; import { TitleBar } from 'electron-react-titlebar/renderer'; import { injectIntl, WrappedComponentProps } from 'react-intl'; import { mdiFlash } from '@mdi/js'; -import { GlobalError } from '../../@types/ferdium-components.types'; +import { Response } from 'electron'; import Link from '../ui/Link'; import InfoBar from '../ui/InfoBar'; import { Component as PublishDebugInfo } from '../../features/publishDebugInfo'; @@ -22,7 +22,7 @@ import { serverName } from '../../api/apiBase'; export interface IProps extends WrappedComponentProps { children: ReactElement; - error: GlobalError; + error: Response; isOnline: boolean; isAPIHealthy: boolean; retryHealthCheck: MouseEventHandler; @@ -106,9 +106,7 @@ class AuthLayout extends Component { )}
{/* Inject globalError into children */} - {cloneElement(children, { - error, - })} + {cloneElement(children, { error })}
{/* */} { - const { intl } = this.props; - return new Form({ - fields: { - name: { - label: intl.formatMessage(messages.name), - placeholder: intl.formatMessage(messages.name), - value: '', - validators: [required], - }, - }, - }); - })(); - - submitForm() { - const { form } = this; - form.submit({ - onSuccess: async f => { - const { onSubmit } = this.props; - const values = f.values(); - onSubmit(values); - }, - }); - } - - render() { - const { intl } = this.props; - const { classes, isSubmitting } = this.props; - const { form } = this; - return ( -
- -
- ); - } -} - -export default injectIntl( - injectSheet(styles, { injectTheme: true })(observer(CreateWorkspaceForm)), -); diff --git a/src/features/workspaces/components/CreateWorkspaceForm.tsx b/src/features/workspaces/components/CreateWorkspaceForm.tsx new file mode 100644 index 000000000..eafe9f36a --- /dev/null +++ b/src/features/workspaces/components/CreateWorkspaceForm.tsx @@ -0,0 +1,97 @@ +import { Component, ReactElement } from 'react'; +import { observer } from 'mobx-react'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import withStyles, { WithStylesProps } from 'react-jss'; +import Input from '../../../components/ui/input/index'; +import Button from '../../../components/ui/button'; +import Form from '../../../lib/Form'; +import { required } from '../../../helpers/validation-helpers'; +import { workspaceStore } from '../index'; + +const messages = defineMessages({ + submitButton: { + id: 'settings.workspace.add.form.submitButton', + defaultMessage: 'Create workspace', + }, + name: { + id: 'settings.workspace.add.form.name', + defaultMessage: 'Name', + }, +}); + +const styles = { + form: { + display: 'flex', + }, + input: { + flexGrow: 1, + marginRight: '10px', + }, + submitButton: { + height: 'inherit', + }, +}; + +interface IProps extends WithStylesProps, WrappedComponentProps { + isSubmitting: boolean; + onSubmit: (...args: any[]) => void; +} + +@observer +class CreateWorkspaceForm extends Component { + form: Form; + + constructor(props: IProps) { + super(props); + + this.form = new Form({ + fields: { + name: { + label: this.props.intl.formatMessage(messages.name), + placeholder: this.props.intl.formatMessage(messages.name), + value: '', + validators: [required], + }, + }, + }); + } + + submitForm(): void { + this.form.submit({ + onSuccess: async form => { + const { onSubmit } = this.props; + const values = form.values(); + onSubmit(values); + }, + }); + } + + render(): ReactElement { + const { classes, isSubmitting, intl } = this.props; + const { form } = this; + + return ( +
+ +
+ ); + } +} + +export default injectIntl( + withStyles(styles, { injectTheme: true })(CreateWorkspaceForm), +); diff --git a/src/features/workspaces/components/EditWorkspaceForm.js b/src/features/workspaces/components/EditWorkspaceForm.js deleted file mode 100644 index ff4e71260..000000000 --- a/src/features/workspaces/components/EditWorkspaceForm.js +++ /dev/null @@ -1,240 +0,0 @@ -import { Component } from 'react'; -import PropTypes from 'prop-types'; -import { observer } from 'mobx-react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { Link } from 'react-router-dom'; -import injectSheet from 'react-jss'; -import Infobox from '../../../components/ui/infobox/index'; -import Input from '../../../components/ui/input/index'; -import Button from '../../../components/ui/button'; -import Workspace from '../models/Workspace'; -import Service from '../../../models/Service'; -import Form from '../../../lib/Form'; -import { required } from '../../../helpers/validation-helpers'; -import WorkspaceServiceListItem from './WorkspaceServiceListItem'; -import Request from '../../../stores/lib/Request'; -import { KEEP_WS_LOADED_USID } from '../../../config'; -import Toggle from '../../../components/ui/toggle'; -import { H2 } from '../../../components/ui/headline'; - -const messages = defineMessages({ - buttonDelete: { - id: 'settings.workspace.form.buttonDelete', - defaultMessage: 'Delete workspace', - }, - buttonSave: { - id: 'settings.workspace.form.buttonSave', - defaultMessage: 'Save workspace', - }, - name: { - id: 'settings.workspace.form.name', - defaultMessage: 'Name', - }, - yourWorkspaces: { - id: 'settings.workspace.form.yourWorkspaces', - defaultMessage: 'Your workspaces', - }, - keepLoaded: { - id: 'settings.workspace.form.keepLoaded', - defaultMessage: 'Keep this workspace loaded*', - }, - keepLoadedInfo: { - id: 'settings.workspace.form.keepLoadedInfo', - defaultMessage: - '*This option will be overwritten by the global "Keep all workspaces loaded" option.', - }, - servicesInWorkspaceHeadline: { - id: 'settings.workspace.form.servicesInWorkspaceHeadline', - defaultMessage: 'Services in this Workspace', - }, - noServicesAdded: { - id: 'settings.services.noServicesAdded', - defaultMessage: 'Start by adding a service.', - }, - discoverServices: { - id: 'settings.services.discoverServices', - defaultMessage: 'Discover services', - }, -}); - -const styles = { - nameInput: { - height: 'auto', - }, - serviceList: { - height: 'auto', - }, - keepLoadedInfo: { - marginBottom: '2rem !important', - }, -}; - -class EditWorkspaceForm extends Component { - static propTypes = { - classes: PropTypes.object.isRequired, - onDelete: PropTypes.func.isRequired, - onSave: PropTypes.func.isRequired, - services: PropTypes.arrayOf(PropTypes.instanceOf(Service)).isRequired, - workspace: PropTypes.instanceOf(Workspace).isRequired, - updateWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, - deleteWorkspaceRequest: PropTypes.instanceOf(Request).isRequired, - }; - - form = this.prepareWorkspaceForm(this.props.workspace); - - // eslint-disable-next-line react/no-deprecated - componentWillReceiveProps(nextProps) { - const { workspace } = this.props; - if (workspace.id !== nextProps.workspace.id) { - this.form = this.prepareWorkspaceForm(nextProps.workspace); - } - } - - prepareWorkspaceForm(workspace) { - const { intl, updateWorkspaceRequest } = this.props; - updateWorkspaceRequest.reset(); - return new Form({ - fields: { - name: { - label: intl.formatMessage(messages.name), - placeholder: intl.formatMessage(messages.name), - value: workspace.name, - validators: [required], - }, - keepLoaded: { - label: intl.formatMessage(messages.keepLoaded), - value: workspace.services.includes(KEEP_WS_LOADED_USID), - default: false, - }, - services: { - value: [...workspace.services], - }, - }, - }); - } - - save(form) { - this.props.updateWorkspaceRequest.reset(); - form.submit({ - onSuccess: async f => { - const { onSave } = this.props; - const values = f.values(); - onSave(values); - }, - onError: async () => {}, - }); - } - - delete() { - const { onDelete } = this.props; - onDelete(); - } - - toggleService(service) { - const servicesField = this.form.$('services'); - const serviceIds = servicesField.value; - if (serviceIds.includes(service.id)) { - serviceIds.splice(serviceIds.indexOf(service.id), 1); - } else { - serviceIds.push(service.id); - } - servicesField.set(serviceIds); - } - - render() { - const { intl } = this.props; - const { - classes, - workspace, - services, - deleteWorkspaceRequest, - updateWorkspaceRequest, - } = this.props; - const { form } = this; - const workspaceServices = form.$('services').value; - const isDeleting = deleteWorkspaceRequest.isExecuting; - const isSaving = updateWorkspaceRequest.isExecuting; - return ( -
-
- - - {intl.formatMessage(messages.yourWorkspaces)} - - - - {workspace.name} -
-
- {updateWorkspaceRequest.error && ( - - Error while saving workspace - - )} -
- - -

- {intl.formatMessage(messages.keepLoadedInfo)} -

-
-

{intl.formatMessage(messages.servicesInWorkspaceHeadline)}

-
- {services.length === 0 ? ( -
- {/* ===== Empty state ===== */} -

- - - - {intl.formatMessage(messages.noServicesAdded)} -

- - {intl.formatMessage(messages.discoverServices)} - -
- ) : ( - <> - {services.map(s => ( - this.toggleService(s)} - /> - ))} - - )} -
-
-
- {/* ===== Delete Button ===== */} -
-
- ); - } -} - -export default injectIntl( - injectSheet(styles, { injectTheme: true })(observer(EditWorkspaceForm)), -); diff --git a/src/features/workspaces/components/EditWorkspaceForm.tsx b/src/features/workspaces/components/EditWorkspaceForm.tsx new file mode 100644 index 000000000..a860ac2e8 --- /dev/null +++ b/src/features/workspaces/components/EditWorkspaceForm.tsx @@ -0,0 +1,247 @@ +import { Component, ReactElement } from 'react'; +import { observer } from 'mobx-react'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { Link } from 'react-router-dom'; +import withStyles, { WithStylesProps } from 'react-jss'; +import Infobox from '../../../components/ui/infobox/index'; +import Input from '../../../components/ui/input'; +import Button from '../../../components/ui/button'; +import Workspace from '../models/Workspace'; +import Service from '../../../models/Service'; +import Form from '../../../lib/Form'; +import { required } from '../../../helpers/validation-helpers'; +import WorkspaceServiceListItem from './WorkspaceServiceListItem'; +import Request from '../../../stores/lib/Request'; +import { KEEP_WS_LOADED_USID } from '../../../config'; +import Toggle from '../../../components/ui/toggle'; +import { H2 } from '../../../components/ui/headline'; + +const messages = defineMessages({ + buttonDelete: { + id: 'settings.workspace.form.buttonDelete', + defaultMessage: 'Delete workspace', + }, + buttonSave: { + id: 'settings.workspace.form.buttonSave', + defaultMessage: 'Save workspace', + }, + name: { + id: 'settings.workspace.form.name', + defaultMessage: 'Name', + }, + yourWorkspaces: { + id: 'settings.workspace.form.yourWorkspaces', + defaultMessage: 'Your workspaces', + }, + keepLoaded: { + id: 'settings.workspace.form.keepLoaded', + defaultMessage: 'Keep this workspace loaded*', + }, + keepLoadedInfo: { + id: 'settings.workspace.form.keepLoadedInfo', + defaultMessage: + '*This option will be overwritten by the global "Keep all workspaces loaded" option.', + }, + servicesInWorkspaceHeadline: { + id: 'settings.workspace.form.servicesInWorkspaceHeadline', + defaultMessage: 'Services in this Workspace', + }, + noServicesAdded: { + id: 'settings.services.noServicesAdded', + defaultMessage: 'Start by adding a service.', + }, + discoverServices: { + id: 'settings.services.discoverServices', + defaultMessage: 'Discover services', + }, +}); + +const styles = { + nameInput: { + height: 'auto', + }, + serviceList: { + height: 'auto', + }, + keepLoadedInfo: { + marginBottom: '2rem !important', + }, +}; + +interface IProps extends WithStylesProps, WrappedComponentProps { + onDelete: () => void; + onSave: (...args: any[]) => void; + services: Service[]; + workspace: Workspace; + updateWorkspaceRequest: Request; + deleteWorkspaceRequest: Request; +} + +@observer +class EditWorkspaceForm extends Component { + form: Form; + + constructor(props: IProps) { + super(props); + + this.form = this.prepareWorkspaceForm(this.props.workspace); + } + + UNSAFE_componentWillReceiveProps(nextProps): void { + const { workspace } = this.props; + if (workspace.id !== nextProps.workspace.id) { + this.form = this.prepareWorkspaceForm(nextProps.workspace); + } + } + + prepareWorkspaceForm(workspace: Workspace): Form { + const { intl, updateWorkspaceRequest } = this.props; + updateWorkspaceRequest.reset(); + + return new Form({ + fields: { + name: { + label: intl.formatMessage(messages.name), + placeholder: intl.formatMessage(messages.name), + value: workspace.name, + validators: [required], + }, + keepLoaded: { + label: intl.formatMessage(messages.keepLoaded), + value: workspace.services.includes(KEEP_WS_LOADED_USID), + default: false, + type: 'checkbox', + }, + services: { + value: [...workspace.services], + }, + }, + }); + } + + save(form): void { + this.props.updateWorkspaceRequest.reset(); + form.submit({ + onSuccess: async f => { + const { onSave } = this.props; + const values = f.values(); + onSave(values); + }, + onError: async () => {}, + }); + } + + delete(): void { + const { onDelete } = this.props; + onDelete(); + } + + toggleService(service: Service): void { + const servicesField = this.form.$('services'); + const serviceIds = servicesField.value; + if (serviceIds.includes(service.id)) { + serviceIds.splice(serviceIds.indexOf(service.id), 1); + } else { + serviceIds.push(service.id); + } + servicesField.set(serviceIds); + } + + render(): ReactElement { + const { + classes, + workspace, + services, + deleteWorkspaceRequest, + updateWorkspaceRequest, + intl, + } = this.props; + const { form } = this; + const workspaceServices = form.$('services').value; + const isDeleting = deleteWorkspaceRequest.isExecuting; + const isSaving = updateWorkspaceRequest.isExecuting; + + return ( +
+
+ + + {intl.formatMessage(messages.yourWorkspaces)} + + + + {workspace.name} +
+
+ {updateWorkspaceRequest.error && ( + + Error while saving workspace + + )} +
+ + +

+ {intl.formatMessage(messages.keepLoadedInfo)} +

+
+

{intl.formatMessage(messages.servicesInWorkspaceHeadline)}

+
+ {services.length === 0 ? ( +
+ {/* ===== Empty state ===== */} +

+ + + + {intl.formatMessage(messages.noServicesAdded)} +

+ + {intl.formatMessage(messages.discoverServices)} + +
+ ) : ( + <> + {services.map(service => ( + this.toggleService(service)} + /> + ))} + + )} +
+
+
+ {/* ===== Delete Button ===== */} +
+
+ ); + } +} + +export default injectIntl( + withStyles(styles, { injectTheme: true })(EditWorkspaceForm), +); diff --git a/src/features/workspaces/components/WorkspaceDrawer.tsx b/src/features/workspaces/components/WorkspaceDrawer.tsx index bdbebdb0a..61284d81a 100644 --- a/src/features/workspaces/components/WorkspaceDrawer.tsx +++ b/src/features/workspaces/components/WorkspaceDrawer.tsx @@ -11,7 +11,6 @@ import WorkspaceDrawerItem from './WorkspaceDrawerItem'; import workspaceActions from '../actions'; import { workspaceStore } from '../index'; import { getUserWorkspacesRequest } from '../api'; -import Service from '../../../models/Service'; import Workspace from '../models/Workspace'; const messages = defineMessages({ @@ -90,7 +89,7 @@ const styles = theme => ({ }); interface IProps extends WithStylesProps, WrappedComponentProps { - getServicesForWorkspace: (workspace: Workspace | null) => Service[]; + getServicesForWorkspace: (workspace: Workspace | null) => string[]; } @observer @@ -150,7 +149,9 @@ class WorkspaceDrawer extends Component { name={workspace.name} isActive={actualWorkspace === workspace} onClick={() => { - if (actualWorkspace === workspace) return; + if (actualWorkspace === workspace) { + return; + } workspaceActions.activate({ workspace }); workspaceActions.toggleWorkspaceDrawer(); }} diff --git a/src/features/workspaces/components/WorkspaceDrawerItem.js b/src/features/workspaces/components/WorkspaceDrawerItem.js deleted file mode 100644 index 22c0a39d9..000000000 --- a/src/features/workspaces/components/WorkspaceDrawerItem.js +++ /dev/null @@ -1,153 +0,0 @@ -import { Menu } from '@electron/remote'; -import { Component } from 'react'; -import PropTypes from 'prop-types'; -import { observer } from 'mobx-react'; -import injectSheet from 'react-jss'; -import classnames from 'classnames'; -import { defineMessages, injectIntl } from 'react-intl'; -import { altKey, cmdOrCtrlShortcutKey } from '../../../environment'; - -const messages = defineMessages({ - noServicesAddedYet: { - id: 'workspaceDrawer.item.noServicesAddedYet', - defaultMessage: 'No services added yet', - }, - contextMenuEdit: { - id: 'workspaceDrawer.item.contextMenuEdit', - defaultMessage: 'edit', - }, -}); - -let itemTransition = 'none'; - -if (window && window.matchMedia('(prefers-reduced-motion: no-preference)')) { - itemTransition = 'background-color 300ms ease-out'; -} - -const styles = theme => ({ - item: { - height: '67px', - padding: `15px ${theme.workspaces.drawer.padding}px`, - borderBottom: `1px solid ${theme.workspaces.drawer.listItem.border}`, - transition: itemTransition, - '&:first-child': { - borderTop: `1px solid ${theme.workspaces.drawer.listItem.border}`, - }, - '&:hover': { - backgroundColor: theme.workspaces.drawer.listItem.hoverBackground, - }, - }, - isActiveItem: { - backgroundColor: theme.workspaces.drawer.listItem.activeBackground, - '&:hover': { - backgroundColor: theme.workspaces.drawer.listItem.activeBackground, - }, - }, - name: { - marginTop: '4px', - color: theme.workspaces.drawer.listItem.name.color, - }, - activeName: { - color: theme.workspaces.drawer.listItem.name.activeColor, - }, - services: { - display: 'block', - fontSize: '11px', - marginTop: '5px', - color: theme.workspaces.drawer.listItem.services.color, - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - overflow: 'hidden', - lineHeight: '15px', - }, - activeServices: { - color: theme.workspaces.drawer.listItem.services.active, - }, -}); - -class WorkspaceDrawerItem extends Component { - static propTypes = { - classes: PropTypes.object.isRequired, - isActive: PropTypes.bool.isRequired, - name: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - services: PropTypes.arrayOf(PropTypes.string).isRequired, - onContextMenuEditClick: PropTypes.func, - shortcutIndex: PropTypes.number.isRequired, - }; - - static defaultProps = { - onContextMenuEditClick: null, - }; - - render() { - const { - classes, - isActive, - name, - onClick, - onContextMenuEditClick, - services, - shortcutIndex, - } = this.props; - - const { intl } = this.props; - - const contextMenuTemplate = [ - { - label: name, - enabled: false, - }, - { - type: 'separator', - }, - { - label: intl.formatMessage(messages.contextMenuEdit), - click: onContextMenuEditClick, - }, - ]; - - const contextMenu = Menu.buildFromTemplate(contextMenuTemplate); - - return ( -
onContextMenuEditClick && contextMenu.popup()} - data-tip={`${ - shortcutIndex <= 9 - ? `(${cmdOrCtrlShortcutKey(false)}+${altKey( - false, - )}+${shortcutIndex})` - : '' - }`} - > - - {name} - - - {services.length > 0 - ? services.join(', ') - : intl.formatMessage(messages.noServicesAddedYet)} - -
- ); - } -} - -export default injectIntl( - injectSheet(styles, { injectTheme: true })(observer(WorkspaceDrawerItem)), -); diff --git a/src/features/workspaces/components/WorkspaceDrawerItem.tsx b/src/features/workspaces/components/WorkspaceDrawerItem.tsx new file mode 100644 index 000000000..0ad56d1ae --- /dev/null +++ b/src/features/workspaces/components/WorkspaceDrawerItem.tsx @@ -0,0 +1,153 @@ +import { Component, MouseEventHandler, ReactElement } from 'react'; +import { observer } from 'mobx-react'; +import withStyles, { WithStylesProps } from 'react-jss'; +import classnames from 'classnames'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { noop } from 'lodash'; +import { Menu } from '@electron/remote'; +import { MenuItemConstructorOptions } from 'electron'; +import { altKey, cmdOrCtrlShortcutKey } from '../../../environment'; + +const messages = defineMessages({ + noServicesAddedYet: { + id: 'workspaceDrawer.item.noServicesAddedYet', + defaultMessage: 'No services added yet', + }, + contextMenuEdit: { + id: 'workspaceDrawer.item.contextMenuEdit', + defaultMessage: 'edit', + }, +}); + +const itemTransition = + window && window.matchMedia('(prefers-reduced-motion: no-preference)') + ? 'background-color 300ms ease-out' + : 'none'; + +const styles = theme => ({ + item: { + height: '67px', + padding: `15px ${theme.workspaces.drawer.padding}px`, + borderBottom: `1px solid ${theme.workspaces.drawer.listItem.border}`, + transition: itemTransition, + '&:first-child': { + borderTop: `1px solid ${theme.workspaces.drawer.listItem.border}`, + }, + '&:hover': { + backgroundColor: theme.workspaces.drawer.listItem.hoverBackground, + }, + }, + isActiveItem: { + backgroundColor: theme.workspaces.drawer.listItem.activeBackground, + '&:hover': { + backgroundColor: theme.workspaces.drawer.listItem.activeBackground, + }, + }, + name: { + marginTop: '4px', + color: theme.workspaces.drawer.listItem.name.color, + }, + activeName: { + color: theme.workspaces.drawer.listItem.name.activeColor, + }, + services: { + display: 'block', + fontSize: '11px', + marginTop: '5px', + color: theme.workspaces.drawer.listItem.services.color, + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflow: 'hidden', + lineHeight: '15px', + }, + activeServices: { + color: theme.workspaces.drawer.listItem.services.active, + }, +}); + +interface IProps extends WithStylesProps, WrappedComponentProps { + isActive: boolean; + name: string; + onClick: MouseEventHandler; + services: string[]; + onContextMenuEditClick?: () => void | null; + shortcutIndex: number; +} + +@observer +class WorkspaceDrawerItem extends Component { + render(): ReactElement { + const { + classes, + isActive, + name, + onClick, + onContextMenuEditClick = null, + services, + shortcutIndex, + intl, + } = this.props; + + const contextMenuTemplate: MenuItemConstructorOptions[] = [ + { + label: name, + enabled: false, + }, + { + type: 'separator', + }, + { + label: intl.formatMessage(messages.contextMenuEdit), + click: onContextMenuEditClick || noop, + }, + ]; + + const contextMenu = Menu.buildFromTemplate(contextMenuTemplate); + + return ( +
{ + if (onContextMenuEditClick) { + contextMenu.popup(); + } + }} + onKeyDown={noop} + data-tip={`${ + shortcutIndex <= 9 + ? `(${cmdOrCtrlShortcutKey(false)}+${altKey( + false, + )}+${shortcutIndex})` + : '' + }`} + > + + {name} + + + {services.length > 0 + ? services.join(', ') + : intl.formatMessage(messages.noServicesAddedYet)} + +
+ ); + } +} + +export default injectIntl( + withStyles(styles, { injectTheme: true })(WorkspaceDrawerItem), +); diff --git a/src/features/workspaces/components/WorkspaceItem.tsx b/src/features/workspaces/components/WorkspaceItem.tsx index eb33a0376..b097a8298 100644 --- a/src/features/workspaces/components/WorkspaceItem.tsx +++ b/src/features/workspaces/components/WorkspaceItem.tsx @@ -1,6 +1,8 @@ -import { Component } from 'react'; +/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ // TODO - [TS DEBT] Need to check and remove it +import { Component, ReactElement } from 'react'; import { observer } from 'mobx-react'; -import injectSheet from 'react-jss'; +import withStyles, { WithStylesProps } from 'react-jss'; +import { noop } from 'lodash'; import Workspace from '../models/Workspace'; const styles = theme => ({ @@ -14,24 +16,24 @@ const styles = theme => ({ columnName: {}, }); -type Props = { - classes: any; - workspace: typeof Workspace; - onItemClick: (workspace) => void; -}; +interface IProps extends WithStylesProps { + workspace: Workspace; + onItemClick: (workspace: Workspace) => void; +} -class WorkspaceItem extends Component { - render() { +@observer +class WorkspaceItem extends Component { + render(): ReactElement { const { classes, workspace, onItemClick } = this.props; return ( - onItemClick(workspace)}>{workspace.name} + onItemClick(workspace)} onKeyDown={noop}> + {workspace.name} + ); } } -export default injectSheet(styles, { injectTheme: true })( - observer(WorkspaceItem), -); +export default withStyles(styles, { injectTheme: true })(WorkspaceItem); diff --git a/src/features/workspaces/components/WorkspaceServiceListItem.tsx b/src/features/workspaces/components/WorkspaceServiceListItem.tsx index e708d5cdf..9034be37c 100644 --- a/src/features/workspaces/components/WorkspaceServiceListItem.tsx +++ b/src/features/workspaces/components/WorkspaceServiceListItem.tsx @@ -2,6 +2,7 @@ import { Component, ReactElement } from 'react'; import { observer } from 'mobx-react'; import withStyles, { WithStylesProps } from 'react-jss'; import classnames from 'classnames'; +import { noop } from 'lodash'; import Toggle from '../../../components/ui/toggle'; import ServiceIcon from '../../../components/ui/ServiceIcon'; import Service from '../../../models/Service'; @@ -39,9 +40,9 @@ interface IProps extends WithStylesProps { class WorkspaceServiceListItem extends Component { render(): ReactElement { const { classes, isInWorkspace, onToggle, service } = this.props; - return ( -
+ // onclick in below div used to fix bug raised under toggle duplicate component removal +
-
-

{intl.formatMessage(messages.headline)}

-
-
- {/* ===== Workspace updated info ===== */} - {updateWorkspaceRequest.wasExecuted && updateWorkspaceRequest.result && ( - - - {intl.formatMessage(messages.updatedInfo)} - - - )} - - {/* ===== Workspace deleted info ===== */} - {deleteWorkspaceRequest.wasExecuted && deleteWorkspaceRequest.result && ( - - - {intl.formatMessage(messages.deletedInfo)} - - - )} - - {/* ===== Create workspace form ===== */} -
- -
- {getUserWorkspacesRequest.isExecuting ? ( - - ) : ( - <> - {/* ===== Workspace could not be loaded error ===== */} - {getUserWorkspacesRequest.error ? ( - - {intl.formatMessage(messages.workspacesRequestFailed)} - - ) : ( - <> - {workspaces.length === 0 ? ( -
- {/* ===== Workspaces empty state ===== */} -

- - - - {intl.formatMessage(messages.noServicesAdded)} -

-
- ) : ( - - {/* ===== Workspaces list ===== */} - - {workspaces.map(workspace => ( - onWorkspaceClick(w)} - /> - ))} - -
- )} - - )} - - )} -
-
- ); - } -} - -export default injectIntl( - inject('stores')( - injectSheet(styles, { injectTheme: true })(observer(WorkspacesDashboard)), - ), -); - -WorkspacesDashboard.propTypes = { - stores: PropTypes.shape({ - ui: PropTypes.instanceOf(UIStore).isRequired, - }).isRequired, -}; diff --git a/src/features/workspaces/components/WorkspacesDashboard.tsx b/src/features/workspaces/components/WorkspacesDashboard.tsx new file mode 100644 index 000000000..60fc7a0ce --- /dev/null +++ b/src/features/workspaces/components/WorkspacesDashboard.tsx @@ -0,0 +1,191 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import { Component, ReactElement } from 'react'; +import { observer } from 'mobx-react'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import withStyles, { WithStylesProps } from 'react-jss'; +import Infobox from '../../../components/ui/infobox/index'; +import Loader from '../../../components/ui/Loader'; +import WorkspaceItem from './WorkspaceItem'; +import CreateWorkspaceForm from './CreateWorkspaceForm'; +import Request from '../../../stores/lib/Request'; +import Appear from '../../../components/ui/effects/Appear'; +import { H1 } from '../../../components/ui/headline'; +import Workspace from '../models/Workspace'; + +const messages = defineMessages({ + headline: { + id: 'settings.workspaces.headline', + defaultMessage: 'Your workspaces', + }, + noServicesAdded: { + id: 'settings.workspaces.noWorkspacesAdded', + defaultMessage: "You haven't created any workspaces yet.", + }, + workspacesRequestFailed: { + id: 'settings.workspaces.workspacesRequestFailed', + defaultMessage: 'Could not load your workspaces', + }, + tryReloadWorkspaces: { + id: 'settings.workspaces.tryReloadWorkspaces', + defaultMessage: 'Try again', + }, + updatedInfo: { + id: 'settings.workspaces.updatedInfo', + defaultMessage: 'Your changes have been saved', + }, + deletedInfo: { + id: 'settings.workspaces.deletedInfo', + defaultMessage: 'Workspace has been deleted', + }, + workspaceFeatureInfo: { + id: 'settings.workspaces.workspaceFeatureInfo', + defaultMessage: + 'Ferdium Workspaces let you focus on what’s important right now. Set up different sets of services and easily switch between them at any time. You decide which services you need when and where, so we can help you stay on top of your game - or easily switch off from work whenever you want.', + }, + workspaceFeatureHeadline: { + id: 'settings.workspaces.workspaceFeatureHeadline', + defaultMessage: 'Less is More: Introducing Ferdium Workspaces', + }, +}); + +const styles = { + table: { + width: '100%', + '& td': { + padding: '10px', + }, + }, + createForm: { + height: 'auto', + }, + appear: { + height: 'auto', + }, + teaserImage: { + width: 250, + margin: [-8, 0, 0, 20], + alignSelf: 'center', + }, +}; + +interface IProps extends WithStylesProps, WrappedComponentProps { + getUserWorkspacesRequest: Request; + createWorkspaceRequest: Request; + deleteWorkspaceRequest: Request; + updateWorkspaceRequest: Request; + onCreateWorkspaceSubmit: (workspace: Workspace) => void; + onWorkspaceClick: (workspace: Workspace) => void; + workspaces: Workspace[]; +} + +@observer +class WorkspacesDashboard extends Component { + render(): ReactElement { + const { + classes, + getUserWorkspacesRequest, + createWorkspaceRequest, + deleteWorkspaceRequest, + updateWorkspaceRequest, + onCreateWorkspaceSubmit, + onWorkspaceClick, + workspaces, + } = this.props; + + const { intl } = this.props; + + return ( +
+
+

{intl.formatMessage(messages.headline)}

+
+
+ {/* ===== Workspace updated info ===== */} + {updateWorkspaceRequest.wasExecuted && updateWorkspaceRequest.result && ( + + + {intl.formatMessage(messages.updatedInfo)} + + + )} + + {/* ===== Workspace deleted info ===== */} + {deleteWorkspaceRequest.wasExecuted && deleteWorkspaceRequest.result && ( + + + {intl.formatMessage(messages.deletedInfo)} + + + )} + + {/* ===== Create workspace form ===== */} +
+ +
+ {getUserWorkspacesRequest.isExecuting ? ( + + ) : ( + <> + {/* ===== Workspace could not be loaded error ===== */} + {getUserWorkspacesRequest.error ? ( + + {intl.formatMessage(messages.workspacesRequestFailed)} + + ) : ( + <> + {workspaces.length === 0 ? ( +
+ {/* ===== Workspaces empty state ===== */} +

+ + + + {intl.formatMessage(messages.noServicesAdded)} +

+
+ ) : ( + + {/* ===== Workspaces list ===== */} + + {workspaces.map(workspace => ( + onWorkspaceClick(w)} + /> + ))} + +
+ )} + + )} + + )} +
+
+ ); + } +} + +export default injectIntl( + withStyles(styles, { injectTheme: true })(WorkspacesDashboard), +); diff --git a/src/features/workspaces/containers/WorkspacesScreen.tsx b/src/features/workspaces/containers/WorkspacesScreen.tsx index d43dc5efa..39f19935f 100644 --- a/src/features/workspaces/containers/WorkspacesScreen.tsx +++ b/src/features/workspaces/containers/WorkspacesScreen.tsx @@ -10,8 +10,13 @@ import { getUserWorkspacesRequest, updateWorkspaceRequest, } from '../api'; +import Workspace from '../models/Workspace'; -class WorkspacesScreen extends Component { +interface IProps extends StoresProps {} + +@inject('stores', 'actions') +@observer +class WorkspacesScreen extends Component { render() { const { actions } = this.props; return ( @@ -23,11 +28,13 @@ class WorkspacesScreen extends Component { deleteWorkspaceRequest={deleteWorkspaceRequest} updateWorkspaceRequest={updateWorkspaceRequest} onCreateWorkspaceSubmit={data => actions.workspaces.create(data)} - onWorkspaceClick={w => actions.workspaces.edit({ workspace: w })} + onWorkspaceClick={(workspace: Workspace) => + actions.workspaces.edit({ workspace }) + } /> ); } } -export default inject('stores', 'actions')(observer(WorkspacesScreen)); +export default WorkspacesScreen; diff --git a/src/stores/AppStore.ts b/src/stores/AppStore.ts index aab279e59..2db90bfa0 100644 --- a/src/stores/AppStore.ts +++ b/src/stores/AppStore.ts @@ -494,7 +494,7 @@ export default class AppStore extends TypedStore { ), ); - await clearAppCache._promise; + await clearAppCache.promise; await sleep(ms('1s')); diff --git a/src/stores/FeaturesStore.ts b/src/stores/FeaturesStore.ts index ed0c6c17b..5f43ccf84 100644 --- a/src/stores/FeaturesStore.ts +++ b/src/stores/FeaturesStore.ts @@ -45,7 +45,7 @@ export default class FeaturesStore extends TypedStore { this._monitorLoginStatus.bind(this), ]); - await this.featuresRequest._promise; + await this.featuresRequest.promise; setTimeout(this._setupFeatures.bind(this), 1); } diff --git a/src/stores/GlobalErrorStore.ts b/src/stores/GlobalErrorStore.ts index c42e9a4af..be86563d0 100644 --- a/src/stores/GlobalErrorStore.ts +++ b/src/stores/GlobalErrorStore.ts @@ -1,4 +1,5 @@ import { observable, action, makeObservable } from 'mobx'; +import { Response } from 'electron'; import { Actions } from '../actions/lib/actions'; import { ApiInterface } from '../api'; import { Stores } from '../@types/stores.types'; @@ -11,12 +12,8 @@ interface Message { message?: string; status?: number; }; - request?: { - result: any; - wasExecuted: any; - method: any; - }; - response?: any; + request?: Request; + response?: Response; server?: any; info?: any; url?: string; @@ -28,7 +25,7 @@ export default class GlobalErrorStore extends TypedStore { @observable messages: Message[] = []; - @observable response: object = {}; + @observable response: Response = {} as Response; // TODO: Get rid of the @ts-ignores in this function. constructor(stores: Stores, api: ApiInterface, actions: Actions) { @@ -85,21 +82,15 @@ export default class GlobalErrorStore extends TypedStore { } } - @action _handleRequests = async (request: { - isError: any; - error: { json: () => object | PromiseLike }; - result: any; - wasExecuted: any; - _method: any; - }): Promise => { + @action _handleRequests = async (request: Request): Promise => { if (request.isError) { this.error = request.error; - if (request.error.json) { + if (request.error && request.error.json) { try { this.response = await request.error.json(); } catch { - this.response = {}; + this.response = {} as Response; } if (this.error?.status === 401) { window['ferdium'].stores.app.authRequestFailed = true; @@ -111,8 +102,8 @@ export default class GlobalErrorStore extends TypedStore { request: { result: request.result, wasExecuted: request.wasExecuted, - method: request._method, - }, + method: request.method, + } as Request, error: this.error, response: this.response, server: window['ferdium'].stores.settings.app.server, diff --git a/src/stores/RecipesStore.ts b/src/stores/RecipesStore.ts index 25304e97c..07f1343f8 100644 --- a/src/stores/RecipesStore.ts +++ b/src/stores/RecipesStore.ts @@ -74,8 +74,8 @@ export default class RecipesStore extends TypedStore { // Actions async _install({ recipeId }): Promise { - const recipe = await this.installRecipeRequest.execute(recipeId)._promise; - await this.allRecipesRequest.invalidate({ immediately: true })._promise; + const recipe = await this.installRecipeRequest.execute(recipeId).promise; + await this.allRecipesRequest.invalidate({ immediately: true }).promise; return recipe; } @@ -128,7 +128,7 @@ export default class RecipesStore extends TypedStore { const update = updates[i]; this.actions.recipe.install({ recipeId: update }); - await this.installRecipeRequest._promise; + await this.installRecipeRequest.promise; this.installRecipeRequest.reset(); @@ -158,10 +158,10 @@ export default class RecipesStore extends TypedStore { debug(`Recipe ${recipeId} is not installed, trying to install it`); const recipe = await this.installRecipeRequest.execute(recipeId) - ._promise; + .promise; if (recipe) { await this.allRecipesRequest.invalidate({ immediately: true }) - ._promise; + .promise; router.push(`/settings/services/add/${recipeId}`); } else { router.push('/settings/recipes'); diff --git a/src/stores/RequestStore.ts b/src/stores/RequestStore.ts index 279615e50..807f2d126 100644 --- a/src/stores/RequestStore.ts +++ b/src/stores/RequestStore.ts @@ -37,6 +37,9 @@ export default class RequestStore extends TypedStore { ); this.registerReactions([this._autoRetry.bind(this)]); + + this.userInfoRequest = {} as CachedRequest; + this.servicesRequest = {} as CachedRequest; } async setup(): Promise { diff --git a/src/stores/ServicesStore.ts b/src/stores/ServicesStore.ts index 74810b81f..4fdd9d5ad 100644 --- a/src/stores/ServicesStore.ts +++ b/src/stores/ServicesStore.ts @@ -477,7 +477,7 @@ export default class ServicesStore extends TypedStore { : this._cleanUpTeamIdAndCustomUrl(recipeId, serviceData); const response = await this.createServiceRequest.execute(recipeId, data) - ._promise; + .promise; this.allServicesRequest.patch(result => { if (!result) return; @@ -536,7 +536,7 @@ export default class ServicesStore extends TypedStore { const newData = serviceData; if (serviceData.iconFile) { - await request._promise; + await request.promise; newData.iconUrl = request.result.data.iconUrl; newData.hasCustomUploadedIcon = true; @@ -562,7 +562,7 @@ export default class ServicesStore extends TypedStore { ); }); - await request._promise; + await request.promise; this.actionStatus = request.result.status; if (service.isEnabled) { @@ -596,7 +596,7 @@ export default class ServicesStore extends TypedStore { remove(result, (c: Service) => c.id === serviceId); }); - await request._promise; + await request.promise; this.actionStatus = request.result.status; } @@ -637,7 +637,7 @@ export default class ServicesStore extends TypedStore { @action async _clearCache({ serviceId }) { this.clearCacheRequest.reset(); const request = this.clearCacheRequest.execute(serviceId); - await request._promise; + await request.promise; } @action _setIsActive(service: Service, state: boolean): void { diff --git a/src/stores/UserStore.ts b/src/stores/UserStore.ts index c5e67c966..6c8f8f20b 100644 --- a/src/stores/UserStore.ts +++ b/src/stores/UserStore.ts @@ -187,7 +187,7 @@ export default class UserStore extends TypedStore { // Actions @action async _login({ email, password }): Promise { - const authToken = await this.loginRequest.execute(email, password)._promise; + const authToken = await this.loginRequest.execute(email, password).promise; this._setUserData(authToken); this.stores.router.push('/'); @@ -209,6 +209,8 @@ export default class UserStore extends TypedStore { plan, currency, }): Promise { + // TODO - [TS DEBT] Need to find a way proper to implement promise's then and catch in request class + // @ts-ignore const authToken = await this.signupRequest.execute({ firstname, lastname, @@ -231,14 +233,14 @@ export default class UserStore extends TypedStore { @action async _retrievePassword({ email }): Promise { const request = this.passwordRequest.execute(email); - await request._promise; + await request.promise; this.actionStatus = request.result.status || []; } @action async _invite({ invites }): Promise { const data = invites.filter(invite => invite.email !== ''); - const response = await this.inviteRequest.execute(data)._promise; + const response = await this.inviteRequest.execute(data).promise; this.actionStatus = response.status || []; @@ -251,8 +253,7 @@ export default class UserStore extends TypedStore { @action async _update({ userData }): Promise { if (!this.isLoggedIn) return; - const response = await this.updateUserInfoRequest.execute(userData) - ._promise; + const response = await this.updateUserInfoRequest.execute(userData).promise; this.getUserInfoRequest.patch(() => response.data); this.actionStatus = response.status || []; @@ -299,7 +300,7 @@ export default class UserStore extends TypedStore { data: service, }); // eslint-disable-next-line no-await-in-loop - await this.stores.services.createServiceRequest._promise; + await this.stores.services.createServiceRequest.promise; } this.isImportLegacyServicesExecuting = false; @@ -349,7 +350,7 @@ export default class UserStore extends TypedStore { if (this.isLoggedIn) { let data; try { - data = await this.getUserInfoRequest.execute()._promise; + data = await this.getUserInfoRequest.execute().promise; } catch { return; } @@ -406,7 +407,7 @@ export default class UserStore extends TypedStore { async _migrateUserLocale(): Promise { try { - await this.getUserInfoRequest._promise; + await this.getUserInfoRequest.promise; } catch { return; } diff --git a/src/stores/lib/CachedRequest.js b/src/stores/lib/CachedRequest.js deleted file mode 100644 index a6dd47f7d..000000000 --- a/src/stores/lib/CachedRequest.js +++ /dev/null @@ -1,121 +0,0 @@ -import { action } from 'mobx'; -import { isEqual, remove } from 'lodash'; -import Request from './Request'; - -export default class CachedRequest extends Request { - _apiCalls = []; - - _isInvalidated = true; - - execute(...callArgs) { - // Do not continue if this request is already loading - if (this._isWaitingForResponse) return this; - - // Very simple caching strategy -> only continue if the call / args changed - // or the request was invalidated manually from outside - const existingApiCall = this._findApiCall(callArgs); - - // Invalidate if new or different api call will be done - if (existingApiCall && existingApiCall !== this._currentApiCall) { - this._isInvalidated = true; - this._currentApiCall = existingApiCall; - } else if (!existingApiCall) { - this._isInvalidated = true; - this._currentApiCall = this._addApiCall(callArgs); - } - - // Do not continue if this request is not invalidated (see above) - if (!this._isInvalidated) return this; - - // This timeout is necessary to avoid warnings from mobx - // regarding triggering actions as side-effect of getters - setTimeout( - action(() => { - this.isExecuting = true; - // Apply the previous result from this call immediately (cached) - if (existingApiCall) { - this.result = existingApiCall.result; - } - }), - 0, - ); - - // Issue api call & save it as promise that is handled to update the results of the operation - this._promise = new Promise(resolve => { - this._api[this._method](...callArgs) - .then(result => { - setTimeout( - action(() => { - this.result = result; - if (this._currentApiCall) this._currentApiCall.result = result; - this.isExecuting = false; - this.isError = false; - this.wasExecuted = true; - this._isInvalidated = false; - this._isWaitingForResponse = false; - this._triggerHooks(); - resolve(result); - }), - 1, - ); - return result; - }) - .catch( - action(error => { - setTimeout( - action(() => { - this.error = error; - this.isExecuting = false; - this.isError = true; - this.wasExecuted = true; - this._isWaitingForResponse = false; - this._triggerHooks(); - // reject(error); - }), - 1, - ); - }), - ); - }); - - this._isWaitingForResponse = true; - return this; - } - - // eslint-disable-next-line unicorn/no-object-as-default-parameter - invalidate(options = { immediately: false }) { - this._isInvalidated = true; - if (options.immediately && this._currentApiCall) { - return this.execute(...this._currentApiCall.args); - } - return this; - } - - patch(modify) { - return new Promise(resolve => { - setTimeout( - action(() => { - const override = modify(this.result); - if (override !== undefined) this.result = override; - if (this._currentApiCall) this._currentApiCall.result = this.result; - resolve(this); - }), - 0, - ); - }); - } - - removeCacheForCallWith(...args) { - remove(this._apiCalls, c => isEqual(c.args, args)); - } - - _addApiCall(args) { - const newCall = { args, result: null }; - this._apiCalls.push(newCall); - return newCall; - } - - _findApiCall(args) { - return this._apiCalls.find(c => isEqual(c.args, args)); - } -} diff --git a/src/stores/lib/CachedRequest.ts b/src/stores/lib/CachedRequest.ts new file mode 100644 index 000000000..25cc365e2 --- /dev/null +++ b/src/stores/lib/CachedRequest.ts @@ -0,0 +1,126 @@ +import { action } from 'mobx'; +import { isEqual, remove } from 'lodash'; +import Request from './Request'; + +export default class CachedRequest extends Request { + _apiCalls: any[] = []; + + _isInvalidated = true; + + execute(...callArgs): this { + // Do not continue if this request is already loading + if (this.isWaitingForResponse) { + return this; + } + + // Very simple caching strategy -> only continue if the call / args changed + // or the request was invalidated manually from outside + const existingApiCall = this._findApiCall(callArgs); + + // Invalidate if new or different api call will be done + if (existingApiCall && existingApiCall !== this.currentApiCall) { + this._isInvalidated = true; + this.currentApiCall = existingApiCall; + } else if (!existingApiCall) { + this._isInvalidated = true; + this.currentApiCall = this._addApiCall(callArgs); + } + + // Do not continue if this request is not invalidated (see above) + if (!this._isInvalidated) { + return this; + } + + // This timeout is necessary to avoid warnings from mobx + // regarding triggering actions as side-effect of getters + setTimeout( + action(() => { + this.isExecuting = true; + // Apply the previous result from this call immediately (cached) + if (existingApiCall) { + this.result = existingApiCall.result; + } + }), + 0, + ); + + // Issue api call & save it as promise that is handled to update the results of the operation + this.promise = new Promise(resolve => { + this.api[this.method](...callArgs) + .then(result => { + setTimeout( + action(() => { + this.result = result; + if (this.currentApiCall) this.currentApiCall.result = result; + this.isExecuting = false; + this.isError = false; + this.wasExecuted = true; + this._isInvalidated = false; + this.isWaitingForResponse = false; + this._triggerHooks(); + resolve(result); + }), + 1, + ); + return result; + }) + .catch( + action(error => { + setTimeout( + action(() => { + this.error = error; + this.isExecuting = false; + this.isError = true; + this.wasExecuted = true; + this.isWaitingForResponse = false; + this._triggerHooks(); + // reject(error); + }), + 1, + ); + }), + ); + }); + + this.isWaitingForResponse = true; + return this; + } + + static defaultOptions = { immediately: false }; + + invalidate(options = CachedRequest.defaultOptions): this { + this._isInvalidated = true; + if (options.immediately && this.currentApiCall) { + return this.execute(...this.currentApiCall.args); + } + return this; + } + + patch(modify): Promise { + return new Promise(resolve => { + setTimeout( + action(() => { + const override = modify(this.result); + if (override !== undefined) this.result = override; + if (this.currentApiCall) this.currentApiCall.result = this.result; + resolve(this); + }), + 0, + ); + }); + } + + removeCacheForCallWith(...args: any): void { + remove(this._apiCalls, c => isEqual(c.args, args)); + } + + _addApiCall(args: any) { + const newCall = { args, result: null }; + this._apiCalls.push(newCall); + return newCall; + } + + _findApiCall(args: any) { + return this._apiCalls.find(c => isEqual(c.args, args)); + } +} diff --git a/src/stores/lib/Request.js b/src/stores/lib/Request.js deleted file mode 100644 index 60c943a42..000000000 --- a/src/stores/lib/Request.js +++ /dev/null @@ -1,155 +0,0 @@ -import { observable, action, computed, makeObservable } from 'mobx'; -import { isEqual } from 'lodash/fp'; - -export default class Request { - static _hooks = []; - - static registerHook(hook) { - Request._hooks.push(hook); - } - - @observable result = null; - - @observable error = null; - - @observable isExecuting = false; - - @observable isError = false; - - @observable wasExecuted = false; - - @action _reset() { - this.error = null; - this.result = null; - this.isExecuting = false; - this.isError = false; - this.wasExecuted = false; - this._isWaitingForResponse = false; - this._promise = Promise; - - return this; - } - - _promise = Promise; - - _api = {}; - - _method = ''; - - _isWaitingForResponse = false; - - _currentApiCall = null; - - constructor(api, method) { - makeObservable(this); - - this._api = api; - this._method = method; - } - - execute(...callArgs) { - // Do not continue if this request is already loading - if (this._isWaitingForResponse) return this; - - if (!this._api[this._method]) { - throw new Error( - `Missing method <${this._method}> on api object:`, - this._api, - ); - } - - // This timeout is necessary to avoid warnings from mobx - // regarding triggering actions as side-effect of getters - setTimeout( - action(() => { - this.isExecuting = true; - }), - 0, - ); - - // Issue api call & save it as promise that is handled to update the results of the operation - this._promise = new Promise((resolve, reject) => { - this._api[this._method](...callArgs) - .then(result => { - setTimeout( - action(() => { - this.error = null; - this.result = result; - if (this._currentApiCall) this._currentApiCall.result = result; - this.isExecuting = false; - this.isError = false; - this.wasExecuted = true; - this._isWaitingForResponse = false; - this._triggerHooks(); - resolve(result); - }), - 1, - ); - return result; - }) - .catch( - action(error => { - setTimeout( - action(() => { - this.error = error; - this.isExecuting = false; - this.isError = true; - this.wasExecuted = true; - this._isWaitingForResponse = false; - this._triggerHooks(); - reject(error); - }), - 1, - ); - }), - ); - }); - - this._isWaitingForResponse = true; - this._currentApiCall = { args: callArgs, result: null }; - return this; - } - - reload() { - const args = this._currentApiCall ? this._currentApiCall.args : []; - this.error = null; - return this.execute(...args); - } - - retry = () => this.reload(); - - isExecutingWithArgs(...args) { - return ( - this.isExecuting && - this._currentApiCall && - isEqual(this._currentApiCall.args, args) - ); - } - - @computed get isExecutingFirstTime() { - return !this.wasExecuted && this.isExecuting; - } - - /* eslint-disable unicorn/no-thenable */ - then(...args) { - if (!this._promise) - throw new Error( - 'You have to call Request::execute before you can access it as promise', - ); - return this._promise.then(...args); - } - - catch(...args) { - if (!this._promise) - throw new Error( - 'You have to call Request::execute before you can access it as promise', - ); - return this._promise.catch(...args); - } - - _triggerHooks() { - for (const hook of Request._hooks) hook(this); - } - - reset = () => this._reset(); -} diff --git a/src/stores/lib/Request.ts b/src/stores/lib/Request.ts new file mode 100644 index 000000000..f9424ac99 --- /dev/null +++ b/src/stores/lib/Request.ts @@ -0,0 +1,159 @@ +import { observable, action, computed, makeObservable } from 'mobx'; +import { isEqual } from 'lodash/fp'; + +type Hook = (request: Request) => void; + +export default class Request { + static _hooks: Hook[] = []; + + static registerHook(hook: Hook) { + Request._hooks.push(hook); + } + + @observable result: any = null; + + @observable error: any = null; + + @observable isExecuting = false; + + @observable isError = false; + + @observable wasExecuted = false; + + promise: any = Promise; + + protected api: any = {}; + + method = ''; + + protected isWaitingForResponse = false; + + protected currentApiCall: any = null; + + retry = () => this.reload(); + + reset = () => this._reset(); + + constructor(api, method) { + makeObservable(this); + + this.api = api; + this.method = method; + } + + @action _reset(): this { + this.error = null; + this.result = null; + this.isExecuting = false; + this.isError = false; + this.wasExecuted = false; + this.isWaitingForResponse = false; + this.promise = Promise; + + return this; + } + + execute(...callArgs: any[]): this { + // Do not continue if this request is already loading + if (this.isWaitingForResponse) return this; + + if (!this.api[this.method]) { + throw new Error( + `Missing method <${this.method}> on api object:`, + this.api, + ); + } + + // This timeout is necessary to avoid warnings from mobx + // regarding triggering actions as side-effect of getters + setTimeout( + action(() => { + this.isExecuting = true; + }), + 0, + ); + + // Issue api call & save it as promise that is handled to update the results of the operation + this.promise = new Promise((resolve, reject) => { + this.api[this.method](...callArgs) + .then(result => { + setTimeout( + action(() => { + this.error = null; + this.result = result; + if (this.currentApiCall) this.currentApiCall.result = result; + this.isExecuting = false; + this.isError = false; + this.wasExecuted = true; + this.isWaitingForResponse = false; + this._triggerHooks(); + resolve(result); + }), + 1, + ); + return result; + }) + .catch( + action(error => { + setTimeout( + action(() => { + this.error = error; + this.isExecuting = false; + this.isError = true; + this.wasExecuted = true; + this.isWaitingForResponse = false; + this._triggerHooks(); + reject(error); + }), + 1, + ); + }), + ); + }); + + this.isWaitingForResponse = true; + this.currentApiCall = { args: callArgs, result: null }; + return this; + } + + reload(): this { + const args = this.currentApiCall ? this.currentApiCall.args : []; + this.error = null; + return this.execute(...args); + } + + isExecutingWithArgs(...args: any[]): boolean { + return ( + this.isExecuting && + this.currentApiCall && + isEqual(this.currentApiCall.args, args) + ); + } + + @computed get isExecutingFirstTime(): boolean { + return !this.wasExecuted && this.isExecuting; + } + + /* eslint-disable unicorn/no-thenable */ + then(...args: any[]) { + if (!this.promise) + throw new Error( + 'You have to call Request::execute before you can access it as promise', + ); + return this.promise.then(...args); + } + + catch(...args: any[]) { + if (!this.promise) + throw new Error( + 'You have to call Request::execute before you can access it as promise', + ); + return this.promise.catch(...args); + } + + _triggerHooks(): void { + for (const hook of Request._hooks) { + hook(this); + } + } +} -- cgit v1.2.3-54-g00ecf