Add basic navigator

This commit is contained in:
dominik-zeglen 2019-11-20 16:58:17 +01:00
parent 29bd1a43a8
commit 8b36ca5308
10 changed files with 391 additions and 0 deletions

5
package-lock.json generated
View file

@ -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",

View file

@ -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",

View 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;

View 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;

View 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;

View file

@ -0,0 +1,2 @@
export { default } from "./Navigator";
export * from "./Navigator";

View file

@ -0,0 +1,8 @@
export type QuickSearchActionType = "view";
export interface QuickSearchAction {
label: string;
score: number;
type: QuickSearchActionType;
url: string;
}

View 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;

View 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;

View file

@ -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({