From 8b36ca5308f2f5d93e1abea800e6a7309eff95e2 Mon Sep 17 00:00:00 2001 From: dominik-zeglen Date: Wed, 20 Nov 2019 16:58:17 +0100 Subject: [PATCH] Add basic navigator --- package-lock.json | 5 + package.json | 1 + src/components/Navigator/Navigator.tsx | 78 +++++++++++ src/components/Navigator/NavigatorInput.tsx | 42 ++++++ src/components/Navigator/NavigatorSection.tsx | 82 +++++++++++ src/components/Navigator/index.ts | 2 + src/components/Navigator/types.ts | 8 ++ src/components/Navigator/useQuickSearch.ts | 39 ++++++ src/components/Navigator/views.ts | 132 ++++++++++++++++++ src/index.tsx | 2 + 10 files changed, 391 insertions(+) create mode 100644 src/components/Navigator/Navigator.tsx create mode 100644 src/components/Navigator/NavigatorInput.tsx create mode 100644 src/components/Navigator/NavigatorSection.tsx create mode 100644 src/components/Navigator/index.ts create mode 100644 src/components/Navigator/types.ts create mode 100644 src/components/Navigator/useQuickSearch.ts create mode 100644 src/components/Navigator/views.ts diff --git a/package-lock.json b/package-lock.json index 37d527f1a..3af4cf4fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11333,6 +11333,11 @@ "integrity": "sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg==", "dev": true }, + "hotkeys-js": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.7.2.tgz", + "integrity": "sha512-LJIPBgejlklphThig2edlYUiPq3iFDHdsXftvZ0VWrpi330dRwD2YxPlzOYv0UQEatvZdgwG3ZLCfJ020ix8vQ==" + }, "hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", diff --git a/package.json b/package.json index 31d82bf87..52c21d332 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "fuzzaldrin": "^2.1.0", "graphql": "^14.4.2", "graphql-tag": "^2.10.1", + "hotkeys-js": "^3.7.2", "is-url": "^1.2.4", "jss": "^9.8.7", "keycode": "^2.2.0", diff --git a/src/components/Navigator/Navigator.tsx b/src/components/Navigator/Navigator.tsx new file mode 100644 index 000000000..1f9adbe28 --- /dev/null +++ b/src/components/Navigator/Navigator.tsx @@ -0,0 +1,78 @@ +import Dialog from "@material-ui/core/Dialog"; +import Downshift from "downshift"; +import hotkeys from "hotkeys-js"; +import React from "react"; +import { useIntl } from "react-intl"; + +import useNavigator from "@saleor/hooks/useNavigator"; +import NavigatorInput from "./NavigatorInput"; +import NavigatorSection from "./NavigatorSection"; +import { QuickSearchAction } from "./types"; +import useQuickSearch from "./useQuickSearch"; +import { getViews, hasViews } from "./views"; + +const navigatorHotkey = "ctrl+m, command+m"; + +const Navigator: React.FC = () => { + const [visible, setVisible] = React.useState(false); + const [query, change, actions] = useQuickSearch(visible); + const navigate = useNavigator(); + const intl = useIntl(); + + React.useEffect(() => { + hotkeys(navigatorHotkey, () => setVisible(true)); + + return () => hotkeys.unbind(navigatorHotkey); + }, []); + + return ( + setVisible(false)} + fullWidth + maxWidth="sm" + > + (item ? item.label : "")} + onSelect={(item: QuickSearchAction) => { + navigate(item.url); + setVisible(false); + }} + onInputValueChange={value => + change({ + target: { + name: "query", + value + } + }) + } + defaultHighlightedIndex={0} + > + {({ getInputProps, getItemProps, highlightedIndex }) => ( +
+ + {hasViews(actions) && ( + + )} +
+ )} +
+
+ ); +}; + +export default Navigator; diff --git a/src/components/Navigator/NavigatorInput.tsx b/src/components/Navigator/NavigatorInput.tsx new file mode 100644 index 000000000..af78b3a93 --- /dev/null +++ b/src/components/Navigator/NavigatorInput.tsx @@ -0,0 +1,42 @@ +import makeStyles from "@material-ui/core/styles/makeStyles"; +import React from "react"; +import { useIntl } from "react-intl"; + +const useStyles = makeStyles( + theme => ({ + root: { + background: theme.palette.background.default, + border: "none", + color: theme.palette.text.primary, + fontSize: 24, + outline: 0, + padding: theme.spacing(2, 3), + width: "100%" + } + }), + { + name: "NavigatorInput" + } +); +const NavigatorInput: React.FC< + React.InputHTMLAttributes +> = props => { + const classes = useStyles(props); + const intl = useIntl(); + + return ( + + ); +}; + +NavigatorInput.displayName = "NavigatorInput"; +export default NavigatorInput; diff --git a/src/components/Navigator/NavigatorSection.tsx b/src/components/Navigator/NavigatorSection.tsx new file mode 100644 index 000000000..c4c5b0623 --- /dev/null +++ b/src/components/Navigator/NavigatorSection.tsx @@ -0,0 +1,82 @@ +import MenuItem from "@material-ui/core/MenuItem"; +import makeStyles from "@material-ui/core/styles/makeStyles"; +import Typography from "@material-ui/core/Typography"; +import { GetItemPropsOptions } from "downshift"; +import React from "react"; + +import Hr from "../Hr"; +import { QuickSearchAction } from "./types"; + +interface NavigatorSectionProps { + getItemProps: (options: GetItemPropsOptions) => any; + highlightedIndex: number; + label: string; + items: QuickSearchAction[]; + offset: number; +} + +const useStyles = makeStyles( + theme => ({ + item: { + "&&&&": { + color: theme.palette.text.secondary, + fontWeight: 400 + }, + margin: theme.spacing(1, 0) + }, + label: { + paddingLeft: theme.spacing(2), + textTransform: "uppercase" + }, + root: { + "&:not(:last-child)": { + marginBottom: theme.spacing(3) + }, + margin: theme.spacing(2, 0), + padding: theme.spacing(0, 1) + } + }), + { + name: "NavigatorSection" + } +); + +const NavigatorSection: React.FC = props => { + const { getItemProps, highlightedIndex, label, items, offset } = props; + + const classes = useStyles(props); + + return ( +
+ + {label} + +
+ {items.map((item, itemIndex) => { + const index = offset + itemIndex; + const itemProps = getItemProps({ + index, + item + }); + + return ( + + {item.label} + + ); + })} +
+ ); +}; + +NavigatorSection.displayName = "NavigatorSection"; +export default NavigatorSection; diff --git a/src/components/Navigator/index.ts b/src/components/Navigator/index.ts new file mode 100644 index 000000000..8816cc013 --- /dev/null +++ b/src/components/Navigator/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Navigator"; +export * from "./Navigator"; diff --git a/src/components/Navigator/types.ts b/src/components/Navigator/types.ts new file mode 100644 index 000000000..4c15f60a9 --- /dev/null +++ b/src/components/Navigator/types.ts @@ -0,0 +1,8 @@ +export type QuickSearchActionType = "view"; + +export interface QuickSearchAction { + label: string; + score: number; + type: QuickSearchActionType; + url: string; +} diff --git a/src/components/Navigator/useQuickSearch.ts b/src/components/Navigator/useQuickSearch.ts new file mode 100644 index 000000000..dc51a9967 --- /dev/null +++ b/src/components/Navigator/useQuickSearch.ts @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { useIntl } from "react-intl"; + +import { ChangeEvent, FormChange } from "@saleor/hooks/useForm"; +import useModalDialogOpen from "@saleor/hooks/useModalDialogOpen"; +import { QuickSearchAction } from "./types"; +import searchInViews from "./views"; + +export type QuickSearchMode = "default" | "orders" | "customers"; +const threshold = 0.05; +const maxActions = 10; + +type UseQuickSearch = [string, FormChange, QuickSearchAction[]]; +function useQuickSearch(open: boolean): UseQuickSearch { + const [query, setQuery] = useState(""); + const [mode, setMode] = useState("default"); + const intl = useIntl(); + + useModalDialogOpen(open, { + onClose: () => setQuery("") + }); + + const change = (event: ChangeEvent) => { + const value = event.target.value; + + setQuery(value); + }; + + return [ + query, + change, + [...searchInViews(query, intl)] + .filter(action => action.score >= threshold) + .sort((a, b) => (a.score <= b.score ? 1 : -1)) + .slice(0, maxActions) + ]; +} + +export default useQuickSearch; diff --git a/src/components/Navigator/views.ts b/src/components/Navigator/views.ts new file mode 100644 index 000000000..000adeaa2 --- /dev/null +++ b/src/components/Navigator/views.ts @@ -0,0 +1,132 @@ +import { score } from "fuzzaldrin"; +import { IntlShape } from "react-intl"; + +import { attributeListUrl } from "@saleor/attributes/urls"; +import { categoryListUrl } from "@saleor/categories/urls"; +import { collectionListUrl } from "@saleor/collections/urls"; +import { customerListUrl } from "@saleor/customers/urls"; +import { saleListUrl, voucherListUrl } from "@saleor/discounts/urls"; +import { sectionNames } from "@saleor/intl"; +import { menuListUrl } from "@saleor/navigation/urls"; +import { orderDraftListUrl, orderListUrl } from "@saleor/orders/urls"; +import { pageListUrl } from "@saleor/pages/urls"; +import { pluginsListUrl } from "@saleor/plugins/urls"; +import { productListUrl } from "@saleor/products/urls"; +import { productTypeListUrl } from "@saleor/productTypes/urls"; +import { serviceListUrl } from "@saleor/services/urls"; +import { shippingZonesListUrl } from "@saleor/shipping/urls"; +import { siteSettingsUrl } from "@saleor/siteSettings/urls"; +import { staffListUrl } from "@saleor/staff/urls"; +import { countryListUrl } from "@saleor/taxes/urls"; +import { languageListUrl } from "@saleor/translations/urls"; +import { webhooksListUrl } from "@saleor/webhooks/urls"; +import { QuickSearchAction } from "./types"; + +interface View { + label: string; + url: string; +} +function searchInViews(search: string, intl: IntlShape): QuickSearchAction[] { + const views: View[] = [ + { + label: intl.formatMessage(sectionNames.attributes), + url: attributeListUrl() + }, + { + label: intl.formatMessage(sectionNames.categories), + url: categoryListUrl() + }, + { + label: intl.formatMessage(sectionNames.collections), + url: collectionListUrl() + }, + { + label: intl.formatMessage(sectionNames.customers), + url: customerListUrl() + }, + { + label: intl.formatMessage(sectionNames.draftOrders), + url: orderDraftListUrl() + }, + { + label: intl.formatMessage(sectionNames.home), + url: "/" + }, + { + label: intl.formatMessage(sectionNames.navigation), + url: menuListUrl() + }, + { + label: intl.formatMessage(sectionNames.orders), + url: orderListUrl() + }, + { + label: intl.formatMessage(sectionNames.pages), + url: pageListUrl() + }, + { + label: intl.formatMessage(sectionNames.plugins), + url: pluginsListUrl() + }, + { + label: intl.formatMessage(sectionNames.productTypes), + url: productTypeListUrl() + }, + { + label: intl.formatMessage(sectionNames.products), + url: productListUrl() + }, + { + label: intl.formatMessage(sectionNames.sales), + url: saleListUrl() + }, + { + label: intl.formatMessage(sectionNames.serviceAccounts), + url: serviceListUrl() + }, + { + label: intl.formatMessage(sectionNames.shipping), + url: shippingZonesListUrl() + }, + { + label: intl.formatMessage(sectionNames.siteSettings), + url: siteSettingsUrl() + }, + { + label: intl.formatMessage(sectionNames.staff), + url: staffListUrl() + }, + { + label: intl.formatMessage(sectionNames.taxes), + url: countryListUrl + }, + { + label: intl.formatMessage(sectionNames.translations), + url: languageListUrl + }, + { + label: intl.formatMessage(sectionNames.vouchers), + url: voucherListUrl() + }, + { + label: intl.formatMessage(sectionNames.webhooks), + url: webhooksListUrl() + } + ]; + + return views.map(view => ({ + label: view.label, + score: score(view.label, search), + type: "view", + url: view.url + })); +} + +export function getViews(actions: QuickSearchAction[]): QuickSearchAction[] { + return actions.filter(action => action.type === "view"); +} +export function hasViews(actions: QuickSearchAction[]): boolean { + return getViews(actions).length > 0; +} + +export default searchInViews; diff --git a/src/index.tsx b/src/index.tsx index b229ec5d1..915593649 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,6 +12,7 @@ import ErrorBoundary from "react-error-boundary"; import { useIntl } from "react-intl"; import { BrowserRouter, Route, Switch } from "react-router-dom"; +import Navigator from "@saleor/components/Navigator"; import useAppState from "@saleor/hooks/useAppState"; import AttributeSection from "./attributes"; import { attributeSection } from "./attributes/urls"; @@ -153,6 +154,7 @@ const Routes: React.FC = () => { }) => isAuthenticated && !tokenAuthLoading && !tokenVerifyLoading ? ( + dispatchAppState({