From 81c43ecc3d17e0dbf7ad1d949b6d977f2c65bd48 Mon Sep 17 00:00:00 2001 From: muhamedsalih-tw <104364298+muhamedsalih-tw@users.noreply.github.com> Date: Thu, 27 Oct 2022 07:13:47 +0530 Subject: fix: 'failed prop' warning in QuickSwitchModal, SettingsNavigation, SettingsWindow and Recipe component tree (#713) * chore: turn off eslint rule @typescript-eslint/no-useless-constructor to initialize dynamic props & state Co-authored-by: Muhamed <> Co-authored-by: Vijay A --- .eslintrc.js | 1 + .../settings/navigation/SettingsNavigation.jsx | 213 ------------ .../settings/navigation/SettingsNavigation.tsx | 203 ++++++++++++ src/components/settings/recipes/RecipeItem.js | 33 -- src/components/settings/recipes/RecipeItem.tsx | 36 +++ .../settings/recipes/RecipesDashboard.jsx | 311 ------------------ .../settings/recipes/RecipesDashboard.tsx | 312 ++++++++++++++++++ src/components/ui/SearchInput.tsx | 89 +++-- src/components/ui/effects/Appear.tsx | 14 +- src/containers/settings/RecipesScreen.tsx | 36 +-- src/containers/settings/SettingsWindow.tsx | 25 +- src/features/quickSwitch/Component.js | 357 --------------------- src/features/quickSwitch/Component.tsx | 349 ++++++++++++++++++++ src/models/RecipePreview.ts | 10 + 14 files changed, 989 insertions(+), 1000 deletions(-) delete mode 100644 src/components/settings/navigation/SettingsNavigation.jsx create mode 100644 src/components/settings/navigation/SettingsNavigation.tsx delete mode 100644 src/components/settings/recipes/RecipeItem.js create mode 100644 src/components/settings/recipes/RecipeItem.tsx delete mode 100644 src/components/settings/recipes/RecipesDashboard.jsx create mode 100644 src/components/settings/recipes/RecipesDashboard.tsx delete mode 100644 src/features/quickSwitch/Component.js create mode 100644 src/features/quickSwitch/Component.tsx diff --git a/.eslintrc.js b/.eslintrc.js index fd4ff7516..585cb3e75 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -73,6 +73,7 @@ module.exports = { // TODO - [TS DEBT] should remove below config once application converted to TS 'react/default-props-match-prop-types': 0, 'react/require-default-props': 0, + '@typescript-eslint/no-useless-constructor': 0, // eslint-plugin-react 'react/destructuring-assignment': 0, 'react/button-has-type': 0, diff --git a/src/components/settings/navigation/SettingsNavigation.jsx b/src/components/settings/navigation/SettingsNavigation.jsx deleted file mode 100644 index e1242a7fe..000000000 --- a/src/components/settings/navigation/SettingsNavigation.jsx +++ /dev/null @@ -1,213 +0,0 @@ -import { Component } from 'react'; -import PropTypes from 'prop-types'; -import { defineMessages, injectIntl } from 'react-intl'; -import { inject, observer } from 'mobx-react'; -import { RouterStore } from '@superwf/mobx-react-router'; - -import { NavLink } from 'react-router-dom'; -import { - LOCAL_SERVER, - LIVE_FERDIUM_API, - LIVE_FRANZ_API, -} from '../../../config'; -import UIStore from '../../../stores/UIStore'; -import SettingsStore from '../../../stores/SettingsStore'; -import UserStore from '../../../stores/UserStore'; -import globalMessages from '../../../i18n/globalMessages'; - -const messages = defineMessages({ - availableServices: { - id: 'settings.navigation.availableServices', - defaultMessage: 'Available services', - }, - yourServices: { - id: 'settings.navigation.yourServices', - defaultMessage: 'Your services', - }, - yourWorkspaces: { - id: 'settings.navigation.yourWorkspaces', - defaultMessage: 'Your workspaces', - }, - account: { - id: 'settings.navigation.account', - defaultMessage: 'Account', - }, - team: { - id: 'settings.navigation.team', - defaultMessage: 'Manage Team', - }, - releaseNotes: { - id: 'settings.navigation.releaseNotes', - defaultMessage: 'Release Notes', - }, - supportFerdium: { - id: 'settings.navigation.supportFerdium', - defaultMessage: 'About Ferdium', - }, - logout: { - id: 'settings.navigation.logout', - defaultMessage: 'Logout', - }, -}); - -class SettingsNavigation extends Component { - static propTypes = { - stores: PropTypes.shape({ - ui: PropTypes.instanceOf(UIStore).isRequired, - user: PropTypes.instanceOf(UserStore).isRequired, - settings: PropTypes.instanceOf(SettingsStore).isRequired, - router: PropTypes.instanceOf(RouterStore).isRequired, - }).isRequired, - actions: PropTypes.shape({ - settings: PropTypes.instanceOf(SettingsStore).isRequired, - }).isRequired, - serviceCount: PropTypes.number.isRequired, - workspaceCount: PropTypes.number.isRequired, - }; - - handleLogout() { - const isUsingWithoutAccount = - this.props.stores.settings.app.server === LOCAL_SERVER; - - // Remove current auth token - localStorage.removeItem('authToken'); - - if (isUsingWithoutAccount) { - // Reset server back to Ferdium API - this.props.actions.settings.update({ - type: 'app', - data: { - server: LIVE_FERDIUM_API, - }, - }); - } - this.props.stores.user.isLoggingOut = true; - - this.props.stores.router.push('/auth/welcome'); - - // Reload Ferdium, otherwise many settings won't sync correctly with the server - // after logging into another account - window.location.reload(); - } - - render() { - const { serviceCount, workspaceCount, stores } = this.props; - const { intl } = this.props; - const isUsingWithoutAccount = stores.settings.app.server === LOCAL_SERVER; - const isUsingFranzServer = stores.settings.app.server === LIVE_FRANZ_API; - - return ( -
- - isActive - ? 'settings-navigation__link is-active' - : 'settings-navigation__link' - } - > - {intl.formatMessage(messages.availableServices)} - - - isActive - ? 'settings-navigation__link is-active' - : 'settings-navigation__link' - } - > - {intl.formatMessage(messages.yourServices)}{' '} - {serviceCount} - - - isActive - ? 'settings-navigation__link is-active' - : 'settings-navigation__link' - } - > - {intl.formatMessage(messages.yourWorkspaces)}{' '} - {workspaceCount} - - {!isUsingWithoutAccount && ( - - isActive - ? 'settings-navigation__link is-active' - : 'settings-navigation__link' - } - > - {intl.formatMessage(messages.account)} - - )} - {isUsingFranzServer && ( - - isActive - ? 'settings-navigation__link is-active' - : 'settings-navigation__link' - } - > - {intl.formatMessage(messages.team)} - - )} - - isActive - ? 'settings-navigation__link is-active' - : 'settings-navigation__link' - } - > - {intl.formatMessage(globalMessages.settings)} - {stores.settings.app.automaticUpdates && - (stores.ui.showServicesUpdatedInfoBar || - stores.app.updateStatus === - stores.app.updateStatusTypes.AVAILABLE || - stores.app.updateStatus === - stores.app.updateStatusTypes.DOWNLOADED) && ( - - )} - - - isActive - ? 'settings-navigation__link is-active' - : 'settings-navigation__link' - } - > - {intl.formatMessage(messages.releaseNotes)} - - - isActive - ? 'settings-navigation__link is-active' - : 'settings-navigation__link' - } - > - {intl.formatMessage(messages.supportFerdium)} - - - -
- ); - } -} - -export default injectIntl( - inject('stores', 'actions')(observer(SettingsNavigation)), -); diff --git a/src/components/settings/navigation/SettingsNavigation.tsx b/src/components/settings/navigation/SettingsNavigation.tsx new file mode 100644 index 000000000..95c69027c --- /dev/null +++ b/src/components/settings/navigation/SettingsNavigation.tsx @@ -0,0 +1,203 @@ +import { Component } from 'react'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { inject, observer } from 'mobx-react'; +import { NavLink } from 'react-router-dom'; +import { StoresProps } from '../../../@types/ferdium-components.types'; +import { + LOCAL_SERVER, + LIVE_FERDIUM_API, + LIVE_FRANZ_API, +} from '../../../config'; +import globalMessages from '../../../i18n/globalMessages'; + +const messages = defineMessages({ + availableServices: { + id: 'settings.navigation.availableServices', + defaultMessage: 'Available services', + }, + yourServices: { + id: 'settings.navigation.yourServices', + defaultMessage: 'Your services', + }, + yourWorkspaces: { + id: 'settings.navigation.yourWorkspaces', + defaultMessage: 'Your workspaces', + }, + account: { + id: 'settings.navigation.account', + defaultMessage: 'Account', + }, + team: { + id: 'settings.navigation.team', + defaultMessage: 'Manage Team', + }, + releaseNotes: { + id: 'settings.navigation.releaseNotes', + defaultMessage: 'Release Notes', + }, + supportFerdium: { + id: 'settings.navigation.supportFerdium', + defaultMessage: 'About Ferdium', + }, + logout: { + id: 'settings.navigation.logout', + defaultMessage: 'Logout', + }, +}); + +interface IProps extends Partial, WrappedComponentProps { + serviceCount: number; + workspaceCount: number; +} + +@inject('stores', 'actions') +@observer +class SettingsNavigation extends Component { + constructor(props: IProps) { + super(props); + } + + handleLogout(): void { + const isUsingWithoutAccount = + this.props.stores!.settings.app.server === LOCAL_SERVER; + + // Remove current auth token + localStorage.removeItem('authToken'); + + if (isUsingWithoutAccount) { + // Reset server back to Ferdium API + this.props.actions!.settings.update({ + type: 'app', + data: { + server: LIVE_FERDIUM_API, + }, + }); + } + this.props.stores!.user.isLoggingOut = true; + + this.props.stores!.router.push('/auth/welcome'); + + // Reload Ferdium, otherwise many settings won't sync correctly with the server + // after logging into another account + window.location.reload(); + } + + render() { + const { serviceCount, workspaceCount, stores, intl } = this.props; + const isUsingWithoutAccount = stores!.settings.app.server === LOCAL_SERVER; + const isUsingFranzServer = stores!.settings.app.server === LIVE_FRANZ_API; + + return ( +
+ + isActive + ? 'settings-navigation__link is-active' + : 'settings-navigation__link' + } + > + {intl.formatMessage(messages.availableServices)} + + + isActive + ? 'settings-navigation__link is-active' + : 'settings-navigation__link' + } + > + {intl.formatMessage(messages.yourServices)}{' '} + {serviceCount} + + + isActive + ? 'settings-navigation__link is-active' + : 'settings-navigation__link' + } + > + {intl.formatMessage(messages.yourWorkspaces)}{' '} + {workspaceCount} + + {!isUsingWithoutAccount && ( + + isActive + ? 'settings-navigation__link is-active' + : 'settings-navigation__link' + } + > + {intl.formatMessage(messages.account)} + + )} + {isUsingFranzServer && ( + + isActive + ? 'settings-navigation__link is-active' + : 'settings-navigation__link' + } + > + {intl.formatMessage(messages.team)} + + )} + + isActive + ? 'settings-navigation__link is-active' + : 'settings-navigation__link' + } + > + {intl.formatMessage(globalMessages.settings)} + {stores!.settings.app.automaticUpdates && + (stores!.ui.showServicesUpdatedInfoBar || + stores!.app.updateStatus === + stores!.app.updateStatusTypes.AVAILABLE || + stores!.app.updateStatus === + stores!.app.updateStatusTypes.DOWNLOADED) && ( + + )} + + + isActive + ? 'settings-navigation__link is-active' + : 'settings-navigation__link' + } + > + {intl.formatMessage(messages.releaseNotes)} + + + isActive + ? 'settings-navigation__link is-active' + : 'settings-navigation__link' + } + > + {intl.formatMessage(messages.supportFerdium)} + + + +
+ ); + } +} + +export default injectIntl(SettingsNavigation); diff --git a/src/components/settings/recipes/RecipeItem.js b/src/components/settings/recipes/RecipeItem.js deleted file mode 100644 index df5b42222..000000000 --- a/src/components/settings/recipes/RecipeItem.js +++ /dev/null @@ -1,33 +0,0 @@ -import { Component } from 'react'; -import PropTypes from 'prop-types'; -import { observer } from 'mobx-react'; - -import RecipePreviewModel from '../../../models/RecipePreview'; - -class RecipeItem extends Component { - static propTypes = { - recipe: PropTypes.instanceOf(RecipePreviewModel).isRequired, - onClick: PropTypes.func.isRequired, - }; - - render() { - const { recipe, onClick } = this.props; - - return ( - - ); - } -} - -export default observer(RecipeItem); diff --git a/src/components/settings/recipes/RecipeItem.tsx b/src/components/settings/recipes/RecipeItem.tsx new file mode 100644 index 000000000..432e4e6a1 --- /dev/null +++ b/src/components/settings/recipes/RecipeItem.tsx @@ -0,0 +1,36 @@ +import { Component, MouseEventHandler } from 'react'; +import { observer } from 'mobx-react'; +import RecipePreview from '../../../models/RecipePreview'; + +interface IProps { + recipe: RecipePreview; + onClick: MouseEventHandler; +} + +@observer +class RecipeItem extends Component { + constructor(props: IProps) { + super(props); + } + + render() { + const { recipe, onClick } = this.props; + + return ( + + ); + } +} + +export default RecipeItem; diff --git a/src/components/settings/recipes/RecipesDashboard.jsx b/src/components/settings/recipes/RecipesDashboard.jsx deleted file mode 100644 index d6150d300..000000000 --- a/src/components/settings/recipes/RecipesDashboard.jsx +++ /dev/null @@ -1,311 +0,0 @@ -import { Component } from 'react'; -import PropTypes from 'prop-types'; -import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { NavLink } from 'react-router-dom'; - -import injectSheet from 'react-jss'; - -import { mdiOpenInNew } from '@mdi/js'; -import Button from '../../ui/button'; -import Input from '../../ui/input/index'; -import { H1, H2, H3 } from '../../ui/headline'; -import SearchInput from '../../ui/SearchInput'; -import Infobox from '../../ui/Infobox'; -import RecipeItem from './RecipeItem'; -import Loader from '../../ui/Loader'; -import Appear from '../../ui/effects/Appear'; -import { FERDIUM_SERVICE_REQUEST } from '../../../config'; -import RecipePreview from '../../../models/RecipePreview'; -import Icon from '../../ui/icon'; - -const messages = defineMessages({ - headline: { - id: 'settings.recipes.headline', - defaultMessage: 'Available services', - }, - searchService: { - id: 'settings.searchService', - defaultMessage: 'Search service', - }, - ferdiumPicksRecipes: { - id: 'settings.recipes.ferdiumPicks', - defaultMessage: 'Ferdium Picks', - }, - allRecipes: { - id: 'settings.recipes.all', - defaultMessage: 'All services', - }, - customRecipes: { - id: 'settings.recipes.custom', - defaultMessage: 'Custom Services', - }, - nothingFound: { - id: 'settings.recipes.nothingFound', - defaultMessage: - 'Sorry, but no service matched your search term - but you can still probably add it using the "Custom Website" option. Please note that the website might show more services that have been added to Ferdium since the version that you are currently on. To get those new services, please consider upgrading to a newer version of Ferdium.', - }, - servicesSuccessfulAddedInfo: { - id: 'settings.recipes.servicesSuccessfulAddedInfo', - defaultMessage: 'Service successfully added', - }, - missingService: { - id: 'settings.recipes.missingService', - defaultMessage: 'Missing a service?', - }, - customRecipeIntro: { - id: 'settings.recipes.customService.intro', - defaultMessage: - 'To add a custom service, copy the service recipe folder inside:', - }, - openFolder: { - id: 'settings.recipes.customService.openFolder', - defaultMessage: 'Open folder', - }, - openDevDocs: { - id: 'settings.recipes.customService.openDevDocs', - defaultMessage: 'Developer Documentation', - }, - headlineCustomRecipes: { - id: 'settings.recipes.customService.headline.customRecipes', - defaultMessage: 'Custom 3rd Party Recipes', - }, - headlineCommunityRecipes: { - id: 'settings.recipes.customService.headline.communityRecipes', - defaultMessage: 'Community 3rd Party Recipes', - }, - headlineDevRecipes: { - id: 'settings.recipes.customService.headline.devRecipes', - defaultMessage: 'Your Development Service Recipes', - }, -}); - -const styles = { - devRecipeIntroContainer: { - textAlign: 'center', - width: '100%', - height: 'auto', - margin: [40, 0], - }, - path: { - marginTop: 20, - - '& > div': { - fontFamily: - 'SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace', - }, - }, - actionContainer: { - '& button': { - margin: [0, 10], - }, - }, - devRecipeList: { - marginTop: 20, - height: 'auto', - }, - proBadge: { - marginLeft: '10px !important', - }, -}; - -class RecipesDashboard extends Component { - static propTypes = { - recipes: MobxPropTypes.arrayOrObservableArray.isRequired, - customWebsiteRecipe: PropTypes.instanceOf(RecipePreview).isRequired, - isLoading: PropTypes.bool.isRequired, - hasLoadedRecipes: PropTypes.bool.isRequired, - showAddServiceInterface: PropTypes.func.isRequired, - searchRecipes: PropTypes.func.isRequired, - resetSearch: PropTypes.func.isRequired, - serviceStatus: MobxPropTypes.arrayOrObservableArray.isRequired, - searchNeedle: PropTypes.string, - recipeFilter: PropTypes.string, - recipeDirectory: PropTypes.string.isRequired, - openRecipeDirectory: PropTypes.func.isRequired, - openDevDocs: PropTypes.func.isRequired, - classes: PropTypes.object.isRequired, - }; - - static defaultProps = { - searchNeedle: '', - recipeFilter: 'all', - }; - - render() { - const { - recipes, - customWebsiteRecipe, - isLoading, - hasLoadedRecipes, - showAddServiceInterface, - searchRecipes, - resetSearch, - serviceStatus, - searchNeedle, - recipeFilter, - recipeDirectory, - openRecipeDirectory, - openDevDocs, - classes, - } = this.props; - const { intl } = this.props; - - const communityRecipes = recipes.filter(r => !r.isDevRecipe); - const devRecipes = recipes.filter(r => r.isDevRecipe); - - return ( -
-
-

{intl.formatMessage(messages.headline)}

-
-
- {serviceStatus.length > 0 && serviceStatus.includes('created') && ( - - - {intl.formatMessage(messages.servicesSuccessfulAddedInfo)} - - - )} - searchRecipes(e)} - onReset={() => resetSearch()} - autoFocus - throttle - /> -
- - recipeFilter === 'featured' ? 'badge badge--primary' : 'badge' - } - onClick={() => resetSearch()} - > - {intl.formatMessage(messages.ferdiumPicksRecipes)} - - - isActive && recipeFilter === 'all' - ? 'badge badge--primary' - : 'badge' - } - onClick={() => resetSearch()} - > - {intl.formatMessage(messages.allRecipes)} - - - isActive && !searchNeedle ? 'badge badge--primary' : 'badge' - } - onClick={() => resetSearch()} - > - {intl.formatMessage(messages.customRecipes)} - - - {intl.formatMessage(messages.missingService)}{' '} - - -
- {/* )} */} - {isLoading ? ( - - ) : ( - <> - {recipeFilter === 'dev' && ( - <> -

{intl.formatMessage(messages.headlineCustomRecipes)}

-
-

{intl.formatMessage(messages.customRecipeIntro)}

- -
-
-
- - )} - {recipeFilter === 'dev' && communityRecipes.length > 0 && ( -

{intl.formatMessage(messages.headlineCommunityRecipes)}

- )} -
- {communityRecipes.map(recipe => ( - - showAddServiceInterface({ recipeId: recipe.id }) - } - /> - ))} -
- {hasLoadedRecipes && - recipes.length === 0 && - recipeFilter !== 'dev' && ( -
- {customWebsiteRecipe && customWebsiteRecipe.id && ( - - showAddServiceInterface({ - recipeId: customWebsiteRecipe.id, - }) - } - /> - )} -

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

-
- )} - {recipeFilter === 'dev' && devRecipes.length > 0 && ( -
-

{intl.formatMessage(messages.headlineDevRecipes)}

-
- {devRecipes.map(recipe => ( - - showAddServiceInterface({ recipeId: recipe.id }) - } - /> - ))} -
-
- )} - - )} -
-
- ); - } -} - -export default injectIntl( - injectSheet(styles, { injectTheme: true })(observer(RecipesDashboard)), -); diff --git a/src/components/settings/recipes/RecipesDashboard.tsx b/src/components/settings/recipes/RecipesDashboard.tsx new file mode 100644 index 000000000..fc687bc79 --- /dev/null +++ b/src/components/settings/recipes/RecipesDashboard.tsx @@ -0,0 +1,312 @@ +import { Component } from 'react'; +import { observer } from 'mobx-react'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { NavLink } from 'react-router-dom'; +import withStyles, { WithStylesProps } from 'react-jss'; +import { mdiOpenInNew } from '@mdi/js'; +import Button from '../../ui/button'; +import Input from '../../ui/input/index'; +import { H1, H2, H3 } from '../../ui/headline'; +import SearchInput from '../../ui/SearchInput'; +import Infobox from '../../ui/infobox'; +import RecipeItem from './RecipeItem'; +import Loader from '../../ui/Loader'; +import Appear from '../../ui/effects/Appear'; +import { FERDIUM_SERVICE_REQUEST } from '../../../config'; +import RecipePreview from '../../../models/RecipePreview'; +import Icon from '../../ui/icon'; + +const messages = defineMessages({ + headline: { + id: 'settings.recipes.headline', + defaultMessage: 'Available services', + }, + searchService: { + id: 'settings.searchService', + defaultMessage: 'Search service', + }, + ferdiumPicksRecipes: { + id: 'settings.recipes.ferdiumPicks', + defaultMessage: 'Ferdium Picks', + }, + allRecipes: { + id: 'settings.recipes.all', + defaultMessage: 'All services', + }, + customRecipes: { + id: 'settings.recipes.custom', + defaultMessage: 'Custom Services', + }, + nothingFound: { + id: 'settings.recipes.nothingFound', + defaultMessage: + 'Sorry, but no service matched your search term - but you can still probably add it using the "Custom Website" option. Please note that the website might show more services that have been added to Ferdium since the version that you are currently on. To get those new services, please consider upgrading to a newer version of Ferdium.', + }, + servicesSuccessfulAddedInfo: { + id: 'settings.recipes.servicesSuccessfulAddedInfo', + defaultMessage: 'Service successfully added', + }, + missingService: { + id: 'settings.recipes.missingService', + defaultMessage: 'Missing a service?', + }, + customRecipeIntro: { + id: 'settings.recipes.customService.intro', + defaultMessage: + 'To add a custom service, copy the service recipe folder inside:', + }, + openFolder: { + id: 'settings.recipes.customService.openFolder', + defaultMessage: 'Open folder', + }, + openDevDocs: { + id: 'settings.recipes.customService.openDevDocs', + defaultMessage: 'Developer Documentation', + }, + headlineCustomRecipes: { + id: 'settings.recipes.customService.headline.customRecipes', + defaultMessage: 'Custom 3rd Party Recipes', + }, + headlineCommunityRecipes: { + id: 'settings.recipes.customService.headline.communityRecipes', + defaultMessage: 'Community 3rd Party Recipes', + }, + headlineDevRecipes: { + id: 'settings.recipes.customService.headline.devRecipes', + defaultMessage: 'Your Development Service Recipes', + }, +}); + +const styles = { + devRecipeIntroContainer: { + textAlign: 'center', + width: '100%', + height: 'auto', + margin: [40, 0], + }, + path: { + marginTop: 20, + + '& > div': { + fontFamily: + 'SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace', + }, + }, + actionContainer: { + '& button': { + margin: [0, 10], + }, + }, + devRecipeList: { + marginTop: 20, + height: 'auto', + }, + proBadge: { + marginLeft: '10px !important', + }, +}; + +interface IProps extends WithStylesProps, WrappedComponentProps { + recipes: RecipePreview[]; + customWebsiteRecipe?: RecipePreview; + isLoading: boolean; + hasLoadedRecipes: boolean; + showAddServiceInterface: (...args: any[]) => void; + searchRecipes: (e: string | null) => void; + resetSearch: () => void; + serviceStatus: string[]; + searchNeedle: string | null; + recipeFilter?: string; + recipeDirectory: string; + openRecipeDirectory: () => void; + openDevDocs: () => void; +} + +interface IState { + searchNeedle: string | null; + recipeFilter: string; +} + +@observer +class RecipesDashboard extends Component { + constructor(props: IProps) { + super(props); + } + + render() { + const { + recipes, + customWebsiteRecipe, + isLoading, + hasLoadedRecipes, + showAddServiceInterface, + searchRecipes, + resetSearch, + serviceStatus = 'all', + searchNeedle = '', + recipeFilter, + recipeDirectory, + openRecipeDirectory, + openDevDocs, + classes, + intl, + } = this.props; + + const communityRecipes = recipes.filter(r => !r.isDevRecipe); + const devRecipes = recipes.filter(r => r.isDevRecipe); + + return ( +
+
+

{intl.formatMessage(messages.headline)}

+
+
+ {serviceStatus.length > 0 && serviceStatus.includes('created') && ( + + + {intl.formatMessage(messages.servicesSuccessfulAddedInfo)} + + + )} + searchRecipes(e)} + onReset={() => resetSearch()} + autoFocus + throttle + /> +
+ + recipeFilter === 'featured' ? 'badge badge--primary' : 'badge' + } + onClick={() => resetSearch()} + > + {intl.formatMessage(messages.ferdiumPicksRecipes)} + + + isActive && recipeFilter === 'all' + ? 'badge badge--primary' + : 'badge' + } + onClick={() => resetSearch()} + > + {intl.formatMessage(messages.allRecipes)} + + + isActive && !searchNeedle ? 'badge badge--primary' : 'badge' + } + onClick={() => resetSearch()} + > + {intl.formatMessage(messages.customRecipes)} + + + {intl.formatMessage(messages.missingService)}{' '} + + +
+ {/* )} */} + {isLoading ? ( + + ) : ( + <> + {recipeFilter === 'dev' && ( + <> +

{intl.formatMessage(messages.headlineCustomRecipes)}

+
+

{intl.formatMessage(messages.customRecipeIntro)}

+ +
+
+
+ + )} + {recipeFilter === 'dev' && communityRecipes.length > 0 && ( +

{intl.formatMessage(messages.headlineCommunityRecipes)}

+ )} +
+ {communityRecipes.map(recipe => ( + + showAddServiceInterface({ recipeId: recipe.id }) + } + /> + ))} +
+ {hasLoadedRecipes && + recipes.length === 0 && + recipeFilter !== 'dev' && ( +
+ {customWebsiteRecipe && customWebsiteRecipe.id && ( + + showAddServiceInterface({ + recipeId: customWebsiteRecipe.id, + }) + } + /> + )} +

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

+
+ )} + {recipeFilter === 'dev' && devRecipes.length > 0 && ( +
+

{intl.formatMessage(messages.headlineDevRecipes)}

+
+ {devRecipes.map(recipe => ( + + showAddServiceInterface({ recipeId: recipe.id }) + } + /> + ))} +
+
+ )} + + )} +
+
+ ); + } +} + +export default injectIntl( + withStyles(styles, { injectTheme: true })(RecipesDashboard), +); diff --git a/src/components/ui/SearchInput.tsx b/src/components/ui/SearchInput.tsx index 6a8c4de8f..39b8f95bf 100644 --- a/src/components/ui/SearchInput.tsx +++ b/src/components/ui/SearchInput.tsx @@ -1,43 +1,35 @@ -import { ChangeEvent, Component } from 'react'; +import { ChangeEvent, Component, ReactElement } from 'react'; import { observer } from 'mobx-react'; import classnames from 'classnames'; -import { debounce } from 'lodash'; +import { debounce, noop } from 'lodash'; import { mdiCloseCircleOutline, mdiMagnify } from '@mdi/js'; import Icon from './icon'; -type Props = { - value: string; +interface IProps { + value?: string; placeholder: string; - className: string; - onChange: (e: ChangeEvent) => void; + className?: string; + onChange?: (e: string) => void; onReset: () => void; - name: string; - throttle: boolean; - throttleDelay: number; - autoFocus: boolean; -}; - -// Should this file be converted into the coding style similar to './toggle/index.tsx'? -class SearchInput extends Component { - static defaultProps = { - value: '', - placeholder: '', - className: '', - name: 'searchInput', - throttle: false, - throttleDelay: 250, - onChange: () => null, - onReset: () => null, - autoFocus: false, - }; - - input = null; - - constructor(props: Props) { + name?: string; + throttle?: boolean; + throttleDelay?: number; + autoFocus?: boolean; +} + +interface IState { + value: string; +} + +@observer +class SearchInput extends Component { + input: HTMLInputElement | null = null; + + constructor(props: IProps) { super(props); this.state = { - value: props.value, + value: props.value || '', }; this.throttledOnChange = debounce( @@ -46,47 +38,47 @@ class SearchInput extends Component { ); } - componentDidMount() { - const { autoFocus } = this.props; + componentDidMount(): void { + const { autoFocus = false } = this.props; - if (autoFocus) { - // @ts-expect-error Object is possibly 'null'. + if (autoFocus && this.input) { this.input.focus(); } } - onChange(e: ChangeEvent) { - const { throttle, onChange } = this.props; + onChange(e: ChangeEvent): void { + const { throttle = false, onChange = noop } = this.props; const { value } = e.target; this.setState({ value }); if (throttle) { e.persist(); - // @ts-expect-error Argument of type 'string' is not assignable to parameter of type 'ChangeEvent'. this.throttledOnChange(value); } else { - // @ts-expect-error Argument of type 'string' is not assignable to parameter of type 'ChangeEvent'. onChange(value); } } - throttledOnChange(e: ChangeEvent) { - const { onChange } = this.props; + throttledOnChange(e: string): void { + const { onChange = noop } = this.props; onChange(e); } - reset() { - const { onReset } = this.props; + reset(): void { + const { onReset = noop } = this.props; this.setState({ value: '' }); onReset(); } - render() { - const { className, name, placeholder } = this.props; - // @ts-expect-error Property 'value' does not exist on type 'Readonly<{}>'. - const { value } = this.state; + render(): ReactElement { + const { + className = '', + name = 'searchInput', + placeholder = '', + } = this.props; + const { value = '' } = this.state; return (
@@ -100,13 +92,12 @@ class SearchInput extends Component { value={value} onChange={e => this.onChange(e)} ref={ref => { - // @ts-expect-error Type 'HTMLInputElement | null' is not assignable to type 'null'. this.input = ref; }} /> {value.length > 0 && ( - this.reset()}> + this.reset()} onKeyDown={noop}> )} @@ -115,4 +106,4 @@ class SearchInput extends Component { } } -export default observer(SearchInput); +export default SearchInput; diff --git a/src/components/ui/effects/Appear.tsx b/src/components/ui/effects/Appear.tsx index 416017c83..bf097b6a6 100644 --- a/src/components/ui/effects/Appear.tsx +++ b/src/components/ui/effects/Appear.tsx @@ -1,16 +1,16 @@ -import { ReactNode, useEffect, useState } from 'react'; +import { ReactElement, ReactNode, useEffect, useState } from 'react'; import { CSSTransitionGroup } from 'react-transition-group'; -type Props = { +interface IProps { children: ReactNode; - transitionName: string; + transitionName?: string; className?: string; -}; +} const Appear = ({ children, transitionName = 'fadeIn', className = '', -}: Props) => { +}: IProps): ReactElement | null => { const [mounted, setMounted] = useState(false); useEffect(() => { @@ -36,8 +36,4 @@ const Appear = ({ ); }; -Appear.defaultProps = { - className: '', -}; - export default Appear; diff --git a/src/containers/settings/RecipesScreen.tsx b/src/containers/settings/RecipesScreen.tsx index fffdd39fa..abbb79b39 100644 --- a/src/containers/settings/RecipesScreen.tsx +++ b/src/containers/settings/RecipesScreen.tsx @@ -16,27 +16,30 @@ import RecipePreview from '../../models/RecipePreview'; import { openPath } from '../../helpers/url-helpers'; import withParams from '../../components/util/WithParams'; -interface RecipesScreenProps extends StoresProps { +interface IProps extends Partial { params: Params; } -class RecipesScreen extends Component { - state: { - needle: string | null; - currentFilter: string; - } = { - needle: null, - currentFilter: 'featured', - }; +interface IState { + needle: string | null; + currentFilter: string; +} +@inject('stores', 'actions') +@observer +class RecipesScreen extends Component { autorunDisposer: IReactionDisposer | null = null; customRecipes: Recipe[] = []; - constructor(props: RecipesScreenProps) { + constructor(props: IProps) { super(props); this.customRecipes = readJsonSync(asarRecipesPath('all.json')); + this.state = { + needle: null, + currentFilter: 'featured', + }; } componentDidMount(): void { @@ -55,7 +58,7 @@ class RecipesScreen extends Component { } componentWillUnmount(): void { - this.props.stores.services.resetStatus(); + this.props.stores!.services.resetStatus(); if (typeof this.autorunDisposer === 'function') { this.autorunDisposer(); @@ -66,7 +69,7 @@ class RecipesScreen extends Component { if (needle === '') { this.resetSearch(); } else { - const { search } = this.props.actions.recipePreview; + const { search } = this.props.actions!.recipePreview; this.setState({ needle }); search({ needle }); } @@ -106,10 +109,8 @@ class RecipesScreen extends Component { } render(): ReactElement { - const { recipePreviews, recipes, services } = this.props.stores; - - const { app: appActions, service: serviceActions } = this.props.actions; - + const { recipePreviews, recipes, services } = this.props.stores!; + const { app: appActions, service: serviceActions } = this.props.actions!; const filter = this.state.currentFilter; let recipeFilter; @@ -163,7 +164,6 @@ class RecipesScreen extends Component { recipes={allRecipes} customWebsiteRecipe={customWebsiteRecipe} isLoading={isLoading} - addedServiceCount={services.all.length} hasLoadedRecipes={ recipePreviews.featuredRecipePreviewsRequest.wasExecuted } @@ -184,4 +184,4 @@ class RecipesScreen extends Component { } } -export default withParams(inject('stores', 'actions')(observer(RecipesScreen))); +export default withParams(RecipesScreen); diff --git a/src/containers/settings/SettingsWindow.tsx b/src/containers/settings/SettingsWindow.tsx index 93bb08c7c..d2cdf3eb3 100644 --- a/src/containers/settings/SettingsWindow.tsx +++ b/src/containers/settings/SettingsWindow.tsx @@ -1,20 +1,23 @@ import { inject, observer } from 'mobx-react'; -import { Component, ReactPortal } from 'react'; +import { Component, ReactElement, ReactPortal } from 'react'; import ReactDOM from 'react-dom'; import { Outlet } from 'react-router-dom'; - import { StoresProps } from '../../@types/ferdium-components.types'; import Navigation from '../../components/settings/navigation/SettingsNavigation'; import Layout from '../../components/settings/SettingsLayout'; import ErrorBoundary from '../../components/util/ErrorBoundary'; import { workspaceStore } from '../../features/workspaces'; -class SettingsContainer extends Component { - portalRoot: any; +interface IProps extends Partial {} + +@inject('stores', 'actions') +@observer +class SettingsContainer extends Component { + portalRoot: HTMLElement | null; el: HTMLDivElement; - constructor(props: StoresProps) { + constructor(props: IProps) { super(props); this.portalRoot = document.querySelector('#portalContainer'); @@ -22,7 +25,9 @@ class SettingsContainer extends Component { } componentDidMount(): void { - this.portalRoot.append(this.el); + if (this.portalRoot) { + this.portalRoot.append(this.el); + } } componentWillUnmount(): void { @@ -31,11 +36,11 @@ class SettingsContainer extends Component { render(): ReactPortal { const { stores } = this.props; - const { closeSettings } = this.props.actions.ui; + const { closeSettings } = this.props.actions!.ui; - const navigation = ( + const navigation: ReactElement = ( ); @@ -51,4 +56,4 @@ class SettingsContainer extends Component { } } -export default inject('stores', 'actions')(observer(SettingsContainer)); +export default SettingsContainer; diff --git a/src/features/quickSwitch/Component.js b/src/features/quickSwitch/Component.js deleted file mode 100644 index 16da22dce..000000000 --- a/src/features/quickSwitch/Component.js +++ /dev/null @@ -1,357 +0,0 @@ -import { Component, createRef } from 'react'; -import { getCurrentWindow } from '@electron/remote'; -import PropTypes from 'prop-types'; -import { observer, inject } from 'mobx-react'; -import { reaction } from 'mobx'; -import injectSheet from 'react-jss'; -import { defineMessages, injectIntl } from 'react-intl'; -import { compact, invoke } from 'lodash'; - -import Input from '../../components/ui/input/index'; -import { H1 } from '../../components/ui/headline'; -import Modal from '../../components/ui/Modal'; -import { state as ModalState } from './store'; -import ServicesStore from '../../stores/ServicesStore'; - -const messages = defineMessages({ - title: { - id: 'feature.quickSwitch.title', - defaultMessage: 'QuickSwitch', - }, - search: { - id: 'feature.quickSwitch.search', - defaultMessage: 'Search...', - }, - info: { - id: 'feature.quickSwitch.info', - defaultMessage: - 'Select a service with TAB, ↑ and ↓. Open a service with ENTER.', - }, -}); - -const styles = theme => ({ - modal: { - width: '80%', - maxWidth: 600, - background: theme.styleTypes.primary.contrast, - paddingTop: 30, - }, - headline: { - fontSize: 20, - marginBottom: 20, - marginTop: -27, - }, - services: { - width: '100%', - maxHeight: '50vh', - overflowX: 'hidden', - overflowY: 'auto', - }, - service: { - background: theme.styleTypes.primary.contrast, - color: theme.colorText, - borderRadius: 6, - padding: '3px 25px', - marginBottom: 10, - display: 'flex', - alignItems: 'center', - '&:last-child': { - marginBottom: 0, - }, - '&:hover': { - cursor: 'pointer', - }, - }, - activeService: { - background: `${theme.styleTypes.primary.accent} !important`, - color: theme.styleTypes.primary.contrast, - cursor: 'pointer', - }, - serviceIcon: { - width: 50, - height: 50, - paddingRight: 20, - objectFit: 'contain', - }, -}); - -class QuickSwitchModal extends Component { - static propTypes = { - classes: PropTypes.object.isRequired, - }; - - state = { - selected: 0, - search: '', - wasPrevVisible: false, - }; - - ARROW_DOWN = 40; - - ARROW_UP = 38; - - ENTER = 13; - - TAB = 9; - - inputRef = createRef(); - - serviceElements = {}; - - constructor(props) { - super(props); - - this._handleKeyDown = this._handleKeyDown.bind(this); - this._handleSearchUpdate = this._handleSearchUpdate.bind(this); - this._handleVisibilityChange = this._handleVisibilityChange.bind(this); - this.openService = this.openService.bind(this); - - reaction( - () => ModalState.isModalVisible, - () => { - this._handleVisibilityChange(); - }, - ); - } - - // Add global keydown listener when component mounts - componentDidMount() { - document.addEventListener('keydown', this._handleKeyDown); - } - - // Remove global keydown listener when component unmounts - componentWillUnmount() { - document.removeEventListener('keydown', this._handleKeyDown); - } - - // Get currently shown services - services() { - let services = []; - if ( - this.state.search && - compact(invoke(this.state.search, 'match', /^[\da-z]/i)).length > 0 - ) { - // Apply simple search algorythm to list of all services - services = this.props.stores.services.allDisplayed; - services = services.filter( - service => - service.name.toLowerCase().search(this.state.search.toLowerCase()) !== - -1, - ); - } else if (this.props.stores.services.allDisplayed.length > 0) { - // Add the currently active service first - const currentService = this.props.stores.services.active; - if (currentService) { - services.push(currentService); - } - - // Add last used services to services array - for (const service of this.props.stores.services.lastUsedServices) { - const tempService = this.props.stores.services.one(service); - if (tempService && !services.includes(tempService)) { - services.push(tempService); - } - } - - // Add all other services in the default order - for (const service of this.props.stores.services.allDisplayed) { - if (!services.includes(service)) { - services.push(service); - } - } - } - - return services; - } - - openService(index) { - // Open service - const service = this.services()[index]; - this.props.actions.service.setActive({ serviceId: service.id }); - - // Reset and close modal - this.setState({ - selected: 0, - search: '', - }); - this.close(); - } - - // Change the selected service - // factor should be -1 or 1 - changeSelected(factor) { - this.setState(state => { - let newSelected = state.selected + factor; - const services = this.services().length; - - // Roll around when on edge of list - if (state.selected < 1 && factor === -1) { - newSelected = services - 1; - } else if (state.selected >= services - 1 && factor === 1) { - newSelected = 0; - } - - // Make sure new selection is visible - const serviceElement = this.serviceElements[newSelected]; - if (serviceElement) { - serviceElement.scrollIntoViewIfNeeded(false); - } - - return { - selected: newSelected, - }; - }); - } - - // Handle global key presses to change the selection - _handleKeyDown(event) { - if (ModalState.isModalVisible) { - switch (event.keyCode) { - case this.ARROW_DOWN: - this.changeSelected(1); - break; - case this.TAB: - if (event.shiftKey) { - this.changeSelected(-1); - } else { - this.changeSelected(1); - } - break; - case this.ARROW_UP: - this.changeSelected(-1); - break; - case this.ENTER: - this.openService(this.state.selected); - break; - default: - break; - } - } - } - - // Handle update of the search query - _handleSearchUpdate(evt) { - this.setState({ - search: evt.target.value, - }); - } - - _handleVisibilityChange() { - const { isModalVisible } = ModalState; - - if (isModalVisible && !this.state.wasPrevVisible) { - // Set focus back on current window if its in a service - // TODO: Find a way to gain back focus - getCurrentWindow().blurWebView(); - getCurrentWindow().webContents.focus(); - - // The input "focus" attribute will only work on first modal open - // Manually add focus to the input element - // Wrapped inside timeout to let the modal render first - setTimeout(() => { - if (this.inputRef.current) { - this.inputRef.current.querySelectorAll('input')[0].focus(); - } - }, 10); - - this.setState({ - wasPrevVisible: true, - }); - } else if (!isModalVisible && this.state.wasPrevVisible) { - // Manually blur focus from the input element to prevent - // search query change when modal not visible - setTimeout(() => { - if (this.inputRef.current) { - this.inputRef.current.querySelectorAll('input')[0].blur(); - } - }, 100); - - this.setState({ - wasPrevVisible: false, - }); - } - } - - // Close this modal - close() { - ModalState.isModalVisible = false; - } - - render() { - const { isModalVisible } = ModalState; - - const { openService } = this; - - const { classes } = this.props; - - const services = this.services(); - - const { intl } = this.props; - - return ( - -

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

-
- -
- -
- {services.map((service, index) => ( -
openService(index)} - key={service.id} - ref={el => { - this.serviceElements[index] = el; - }} - > - {service.recipe.name} -
{service.name}
-
- ))} -
- -

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

-
- ); - } -} - -QuickSwitchModal.propTypes = { - stores: PropTypes.shape({ - services: PropTypes.instanceOf(ServicesStore).isRequired, - }).isRequired, - actions: PropTypes.shape({ - service: PropTypes.instanceOf(ServicesStore).isRequired, - }).isRequired, - classes: PropTypes.object.isRequired, -}; - -export default injectIntl( - injectSheet(styles, { injectTheme: true })( - inject('stores', 'actions')(observer(QuickSwitchModal)), - ), -); diff --git a/src/features/quickSwitch/Component.tsx b/src/features/quickSwitch/Component.tsx new file mode 100644 index 000000000..fb85d61e1 --- /dev/null +++ b/src/features/quickSwitch/Component.tsx @@ -0,0 +1,349 @@ +import { ChangeEvent, Component, createRef, ReactElement } from 'react'; +import { getCurrentWindow } from '@electron/remote'; +import { observer, inject } from 'mobx-react'; +import { reaction } from 'mobx'; +import withStyles, { WithStylesProps } from 'react-jss'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { compact, invoke, noop } from 'lodash'; +import { StoresProps } from '../../@types/ferdium-components.types'; +import Service from '../../models/Service'; +import Input from '../../components/ui/input/index'; +import { H1 } from '../../components/ui/headline'; +import Modal from '../../components/ui/Modal'; +import { state as ModalState } from './store'; + +const messages = defineMessages({ + title: { + id: 'feature.quickSwitch.title', + defaultMessage: 'QuickSwitch', + }, + search: { + id: 'feature.quickSwitch.search', + defaultMessage: 'Search...', + }, + info: { + id: 'feature.quickSwitch.info', + defaultMessage: + 'Select a service with TAB, ↑ and ↓. Open a service with ENTER.', + }, +}); + +const styles = theme => ({ + modal: { + width: '80%', + maxWidth: 600, + background: theme.styleTypes.primary.contrast, + paddingTop: 30, + }, + headline: { + fontSize: 20, + marginBottom: 20, + marginTop: -27, + }, + services: { + width: '100%', + maxHeight: '50vh', + overflowX: 'hidden', + overflowY: 'auto', + }, + service: { + background: theme.styleTypes.primary.contrast, + color: theme.colorText, + borderRadius: 6, + padding: '3px 25px', + marginBottom: 10, + display: 'flex', + alignItems: 'center', + '&:last-child': { + marginBottom: 0, + }, + '&:hover': { + cursor: 'pointer', + }, + }, + activeService: { + background: `${theme.styleTypes.primary.accent} !important`, + color: theme.styleTypes.primary.contrast, + cursor: 'pointer', + }, + serviceIcon: { + width: 50, + height: 50, + paddingRight: 20, + objectFit: 'contain', + }, +}); + +interface IProps + extends WithStylesProps, + Partial, + WrappedComponentProps {} + +interface IState { + selected: number; + search: string; + wasPrevVisible: boolean; +} + +@inject('stores', 'actions') +@observer +class QuickSwitchModal extends Component { + ARROW_DOWN = 40; + + ARROW_UP = 38; + + ENTER = 13; + + TAB = 9; + + inputRef = createRef(); + + serviceElements = {}; + + constructor(props) { + super(props); + + this.state = { + selected: 0, + search: '', + wasPrevVisible: false, + }; + + this._handleKeyDown = this._handleKeyDown.bind(this); + this._handleSearchUpdate = this._handleSearchUpdate.bind(this); + this._handleVisibilityChange = this._handleVisibilityChange.bind(this); + this.openService = this.openService.bind(this); + + reaction( + () => ModalState.isModalVisible, + () => { + this._handleVisibilityChange(); + }, + ); + } + + // Add global keydown listener when component mounts + componentDidMount(): void { + document.addEventListener('keydown', this._handleKeyDown); + } + + // Remove global keydown listener when component unmounts + componentWillUnmount(): void { + document.removeEventListener('keydown', this._handleKeyDown); + } + + // Get currently shown services + services(): Service[] { + let services: Service[] = []; + if ( + this.state.search && + compact(invoke(this.state.search, 'match', /^[\da-z]/i)).length > 0 + ) { + // Apply simple search algorythm to list of all services + services = this.props.stores!.services.allDisplayed; + services = services.filter( + service => + service.name.toLowerCase().search(this.state.search.toLowerCase()) !== + -1, + ); + } else if (this.props.stores!.services.allDisplayed.length > 0) { + // Add the currently active service first + const currentService = this.props.stores!.services.active; + if (currentService) { + services.push(currentService); + } + + // Add last used services to services array + for (const service of this.props.stores!.services.lastUsedServices) { + const tempService = this.props.stores!.services.one(service); + if (tempService && !services.includes(tempService)) { + services.push(tempService); + } + } + + // Add all other services in the default order + for (const service of this.props.stores!.services.allDisplayed) { + if (!services.includes(service)) { + services.push(service); + } + } + } + + return services; + } + + openService(index): void { + // Open service + const service = this.services()[index]; + this.props.actions!.service.setActive({ serviceId: service.id }); + + // Reset and close modal + this.setState({ + selected: 0, + search: '', + }); + this.close(); + } + + // Change the selected service + // factor should be -1 or 1 + changeSelected(factor: number): any { + this.setState(state => { + let newSelected = state.selected + factor; + const services = this.services().length; + + // Roll around when on edge of list + if (state.selected < 1 && factor === -1) { + newSelected = services - 1; + } else if (state.selected >= services - 1 && factor === 1) { + newSelected = 0; + } + + // Make sure new selection is visible + const serviceElement = this.serviceElements[newSelected]; + if (serviceElement) { + serviceElement.scrollIntoViewIfNeeded(false); + } + + return { + selected: newSelected, + }; + }); + } + + // Handle global key presses to change the selection + _handleKeyDown(event: KeyboardEvent): void { + if (ModalState.isModalVisible) { + switch (event.keyCode) { + case this.ARROW_DOWN: + this.changeSelected(1); + break; + case this.TAB: + if (event.shiftKey) { + this.changeSelected(-1); + } else { + this.changeSelected(1); + } + break; + case this.ARROW_UP: + this.changeSelected(-1); + break; + case this.ENTER: + this.openService(this.state.selected); + break; + default: + break; + } + } + } + + // Handle update of the search query + _handleSearchUpdate(event: ChangeEvent): void { + this.setState({ + search: event.target.value, + }); + } + + _handleVisibilityChange(): void { + const { isModalVisible } = ModalState; + + if (isModalVisible && !this.state.wasPrevVisible) { + // Set focus back on current window if its in a service + // TODO: Find a way to gain back focus + getCurrentWindow().blurWebView(); + getCurrentWindow().webContents.focus(); + + // The input "focus" attribute will only work on first modal open + // Manually add focus to the input element + // Wrapped inside timeout to let the modal render first + setTimeout(() => { + if (this.inputRef.current) { + this.inputRef.current.querySelectorAll('input')[0].focus(); + } + }, 10); + + this.setState({ + wasPrevVisible: true, + }); + } else if (!isModalVisible && this.state.wasPrevVisible) { + // Manually blur focus from the input element to prevent + // search query change when modal not visible + setTimeout(() => { + if (this.inputRef.current) { + this.inputRef.current.querySelectorAll('input')[0].blur(); + } + }, 100); + + this.setState({ + wasPrevVisible: false, + }); + } + } + + // Close this modal + close(): void { + ModalState.isModalVisible = false; + } + + render(): ReactElement { + const { isModalVisible } = ModalState; + const { openService } = this; + const { classes, intl } = this.props; + const services = this.services(); + + return ( + +

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

+
+ +
+ +
+ {services.map((service, index) => ( +
openService(index)} + onKeyDown={noop} + key={service.id} + ref={el => { + this.serviceElements[index] = el; + }} + > + {service.recipe.name} +
{service.name}
+
+ ))} +
+ +

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

+
+ ); + } +} + +export default injectIntl( + withStyles(styles, { injectTheme: true })(QuickSwitchModal), +); diff --git a/src/models/RecipePreview.ts b/src/models/RecipePreview.ts index fb8cb3e3e..33b5e1432 100644 --- a/src/models/RecipePreview.ts +++ b/src/models/RecipePreview.ts @@ -4,6 +4,10 @@ interface IRecipePreview { icon: string; featured: boolean; aliases: string[]; + isDevRecipe?: boolean; + icons?: { + svg: string; + }; } export default class RecipePreview { @@ -17,6 +21,12 @@ export default class RecipePreview { aliases: string[] = []; + isDevRecipe?: boolean; + + icons?: { + svg: string; + }; + constructor(data: IRecipePreview) { if (!data) { throw new Error('RecipePreview config not valid'); -- cgit v1.2.3-54-g00ecf