Add basic navigator
This commit is contained in:
parent
29bd1a43a8
commit
8b36ca5308
10 changed files with 391 additions and 0 deletions
5
package-lock.json
generated
5
package-lock.json
generated
|
@ -11333,6 +11333,11 @@
|
||||||
"integrity": "sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg==",
|
"integrity": "sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg==",
|
||||||
"dev": true
|
"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": {
|
"hpack.js": {
|
||||||
"version": "2.1.6",
|
"version": "2.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
|
||||||
|
|
|
@ -39,6 +39,7 @@
|
||||||
"fuzzaldrin": "^2.1.0",
|
"fuzzaldrin": "^2.1.0",
|
||||||
"graphql": "^14.4.2",
|
"graphql": "^14.4.2",
|
||||||
"graphql-tag": "^2.10.1",
|
"graphql-tag": "^2.10.1",
|
||||||
|
"hotkeys-js": "^3.7.2",
|
||||||
"is-url": "^1.2.4",
|
"is-url": "^1.2.4",
|
||||||
"jss": "^9.8.7",
|
"jss": "^9.8.7",
|
||||||
"keycode": "^2.2.0",
|
"keycode": "^2.2.0",
|
||||||
|
|
78
src/components/Navigator/Navigator.tsx
Normal file
78
src/components/Navigator/Navigator.tsx
Normal file
|
@ -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 (
|
||||||
|
<Dialog
|
||||||
|
open={visible}
|
||||||
|
onClose={() => setVisible(false)}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="sm"
|
||||||
|
>
|
||||||
|
<Downshift
|
||||||
|
itemToString={(item: QuickSearchAction) => (item ? item.label : "")}
|
||||||
|
onSelect={(item: QuickSearchAction) => {
|
||||||
|
navigate(item.url);
|
||||||
|
setVisible(false);
|
||||||
|
}}
|
||||||
|
onInputValueChange={value =>
|
||||||
|
change({
|
||||||
|
target: {
|
||||||
|
name: "query",
|
||||||
|
value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defaultHighlightedIndex={0}
|
||||||
|
>
|
||||||
|
{({ getInputProps, getItemProps, highlightedIndex }) => (
|
||||||
|
<div>
|
||||||
|
<NavigatorInput
|
||||||
|
value={query}
|
||||||
|
{...getInputProps({
|
||||||
|
value: query
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{hasViews(actions) && (
|
||||||
|
<NavigatorSection
|
||||||
|
label={intl.formatMessage({
|
||||||
|
defaultMessage: "Navigate to",
|
||||||
|
description: "navigator section header"
|
||||||
|
})}
|
||||||
|
getItemProps={getItemProps}
|
||||||
|
highlightedIndex={highlightedIndex}
|
||||||
|
items={getViews(actions)}
|
||||||
|
offset={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Downshift>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navigator;
|
42
src/components/Navigator/NavigatorInput.tsx
Normal file
42
src/components/Navigator/NavigatorInput.tsx
Normal file
|
@ -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<HTMLInputElement>
|
||||||
|
> = props => {
|
||||||
|
const classes = useStyles(props);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
className={classes.root}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
defaultMessage: "Use Navigator to move through Saleor",
|
||||||
|
description: "navigator placeholder"
|
||||||
|
})}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
NavigatorInput.displayName = "NavigatorInput";
|
||||||
|
export default NavigatorInput;
|
82
src/components/Navigator/NavigatorSection.tsx
Normal file
82
src/components/Navigator/NavigatorSection.tsx
Normal file
|
@ -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<NavigatorSectionProps> = props => {
|
||||||
|
const { getItemProps, highlightedIndex, label, items, offset } = props;
|
||||||
|
|
||||||
|
const classes = useStyles(props);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<Typography
|
||||||
|
className={classes.label}
|
||||||
|
variant="body2"
|
||||||
|
color="textSecondary"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Hr />
|
||||||
|
{items.map((item, itemIndex) => {
|
||||||
|
const index = offset + itemIndex;
|
||||||
|
const itemProps = getItemProps({
|
||||||
|
index,
|
||||||
|
item
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
{...itemProps}
|
||||||
|
className={classes.item}
|
||||||
|
selected={highlightedIndex === index}
|
||||||
|
key={[item.label, item.type].join(":")}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
NavigatorSection.displayName = "NavigatorSection";
|
||||||
|
export default NavigatorSection;
|
2
src/components/Navigator/index.ts
Normal file
2
src/components/Navigator/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from "./Navigator";
|
||||||
|
export * from "./Navigator";
|
8
src/components/Navigator/types.ts
Normal file
8
src/components/Navigator/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
export type QuickSearchActionType = "view";
|
||||||
|
|
||||||
|
export interface QuickSearchAction {
|
||||||
|
label: string;
|
||||||
|
score: number;
|
||||||
|
type: QuickSearchActionType;
|
||||||
|
url: string;
|
||||||
|
}
|
39
src/components/Navigator/useQuickSearch.ts
Normal file
39
src/components/Navigator/useQuickSearch.ts
Normal file
|
@ -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<QuickSearchMode>("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;
|
132
src/components/Navigator/views.ts
Normal file
132
src/components/Navigator/views.ts
Normal file
|
@ -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;
|
|
@ -12,6 +12,7 @@ import ErrorBoundary from "react-error-boundary";
|
||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
import { BrowserRouter, Route, Switch } from "react-router-dom";
|
import { BrowserRouter, Route, Switch } from "react-router-dom";
|
||||||
|
|
||||||
|
import Navigator from "@saleor/components/Navigator";
|
||||||
import useAppState from "@saleor/hooks/useAppState";
|
import useAppState from "@saleor/hooks/useAppState";
|
||||||
import AttributeSection from "./attributes";
|
import AttributeSection from "./attributes";
|
||||||
import { attributeSection } from "./attributes/urls";
|
import { attributeSection } from "./attributes/urls";
|
||||||
|
@ -153,6 +154,7 @@ const Routes: React.FC = () => {
|
||||||
}) =>
|
}) =>
|
||||||
isAuthenticated && !tokenAuthLoading && !tokenVerifyLoading ? (
|
isAuthenticated && !tokenAuthLoading && !tokenVerifyLoading ? (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
|
<Navigator />
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onError={() =>
|
onError={() =>
|
||||||
dispatchAppState({
|
dispatchAppState({
|
||||||
|
|
Loading…
Reference in a new issue