diff --git a/CHANGELOG.md b/CHANGELOG.md index 4412f16e9..00732ebd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable, unreleased changes to this project will be documented in this file. - Add custom request headers to webhook form - #3107 by @2can - Allow subscription query for sync events - #3099 by @2can - Simplify codegen configuration to generate the graphql schema - #2538 by @abumalick +- Add DevMode panel (trigger: CMD+') - #3333 by @zaiste ## 3.4 diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 9e3bbca9d..d9f0b5474 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -7345,6 +7345,10 @@ "context": "stock exceeded dialog title", "string": "Not enough stock" }, + "qa/dDe": { + "context": "GraphiQL Playground in the dev mode panel", + "string": "Playground" + }, "qbFKVI": { "context": "notification, form submitted", "string": "Refund for order #{orderNumber} was granted" diff --git a/src/components/AppLayout/AppLayout.tsx b/src/components/AppLayout/AppLayout.tsx index 230d44cff..e5f7fe597 100644 --- a/src/components/AppLayout/AppLayout.tsx +++ b/src/components/AppLayout/AppLayout.tsx @@ -2,8 +2,11 @@ import useAppState from "@dashboard/hooks/useAppState"; import { LinearProgress } from "@material-ui/core"; import { useActionBar } from "@saleor/macaw-ui"; import { Box } from "@saleor/macaw-ui/next"; -import React from "react"; +import React, { useState } from "react"; +import { DevModePanel } from "../DevModePanel/DevModePanel"; +import { useDevModeContext } from "../DevModePanel/hooks"; +import { useDevModeKeyTrigger } from "../DevModePanel/useDevModeKeyTrigger"; import Navigator from "../Navigator"; import { Sidebar } from "../Sidebar"; import { contentMaxWidth } from "./consts"; @@ -18,10 +21,18 @@ const AppLayout: React.FC = ({ children }) => { const classes = useStyles(); const { anchor: appActionAnchor } = useActionBar(); const [appState] = useAppState(); - const [isNavigatorVisible, setNavigatorVisibility] = React.useState(false); + const [isNavigatorVisible, setNavigatorVisibility] = useState(false); + + const { isDevModeVisible, setDevModeVisibility } = useDevModeContext(); + + useDevModeKeyTrigger(() => setDevModeVisibility(!isDevModeVisible)); return ( <> + void; +} + +export const DevModePanel: React.FC = ({ + isDevModeVisible, + setDevModeVisibility, +}) => { + const fetcher = createGraphiQLFetcher({ + url: process.env.API_URI, + fetch: authorizedFetch, + }); + + const intl = useIntl(); + + const { devModeContent, variables } = useDevModeContext(); + + return ( + + setDevModeVisibility(false)}> + {intl.formatMessage(messages.title)} + + + + + + ); +}; diff --git a/src/components/DevModePanel/DevModeProvider.tsx b/src/components/DevModePanel/DevModeProvider.tsx new file mode 100644 index 000000000..25ddc90c0 --- /dev/null +++ b/src/components/DevModePanel/DevModeProvider.tsx @@ -0,0 +1,27 @@ +import React, { useState } from "react"; + +import { DevModeContext } from "./hooks"; + +export function DevModeProvider({ children }) { + // stringified variables (as key/value) passed along with the query + const [variables, setVariables] = useState(""); + // stringified GraphQL query; queries can be constructed anywhere in the + // dashboard to be passed to the dev mode panel + const [devModeContent, setDevModeContent] = useState(""); + const [isDevModeVisible, setDevModeVisibility] = useState(false); + + return ( + + {children} + + ); +} diff --git a/src/components/DevModePanel/hooks.ts b/src/components/DevModePanel/hooks.ts new file mode 100644 index 000000000..b4f5652a6 --- /dev/null +++ b/src/components/DevModePanel/hooks.ts @@ -0,0 +1,22 @@ +import { createContext, Dispatch, SetStateAction, useContext } from "react"; + +interface DevModeContextShape { + variables: string; + setVariables: Dispatch>; + isDevModeVisible: boolean; + setDevModeVisibility: Dispatch>; + devModeContent: string; + setDevModeContent: Dispatch>; +} + +export const DevModeContext = createContext(null); + +export const useDevModeContext = () => { + const context = useContext(DevModeContext); + + if (context === null) { + throw new Error("you are outside of context"); + } + + return context; +}; diff --git a/src/components/DevModePanel/index.ts b/src/components/DevModePanel/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/DevModePanel/messages.ts b/src/components/DevModePanel/messages.ts new file mode 100644 index 000000000..f81801f70 --- /dev/null +++ b/src/components/DevModePanel/messages.ts @@ -0,0 +1,9 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + title: { + id: "qa/dDe", + defaultMessage: "Playground", + description: "GraphiQL Playground in the dev mode panel", + }, +}); diff --git a/src/components/DevModePanel/useDevModeKeyTrigger.ts b/src/components/DevModePanel/useDevModeKeyTrigger.ts new file mode 100644 index 000000000..639342fd3 --- /dev/null +++ b/src/components/DevModePanel/useDevModeKeyTrigger.ts @@ -0,0 +1,14 @@ +import { useEffect } from "react"; + +export const useDevModeKeyTrigger = (callback?: () => void) => { + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.metaKey && event.code === "Quote") { + callback(); + } + }; + + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [callback]); +}; diff --git a/src/components/GraphiQL/GraphiQL.tsx b/src/components/GraphiQL/GraphiQL.tsx index 70486f49a..042d89211 100644 --- a/src/components/GraphiQL/GraphiQL.tsx +++ b/src/components/GraphiQL/GraphiQL.tsx @@ -279,10 +279,7 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) { aria-label="Query Editor" style={{ borderBottom: 0 }} > -
+
{ }; export const useDashboardTheme = () => { - const theme = useTheme(); - const rootStyle = { - "--font-size-body": theme.typography?.body2.fontSize, - "--font-size-h2": theme.typography?.h3.fontSize, - "--font-size-h3": theme.typography?.h3.fontSize, - "--font-size-h4": theme.typography?.h4.fontSize, - "--font-size-hint": theme.typography?.caption.fontSize, - "--font-size-inline-code": theme.typography?.caption.fontSize, + "--font-size-body": vars.fontSize.bodyMedium, + "--font-size-h2": vars.fontSize.headingLarge, + "--font-size-h3": vars.fontSize.headingMedium, + "--font-size-h4": vars.fontSize.headingSmall, + "--font-weight-regular": vars.fontWeight.bodyLarge, + "--font-size-hint": vars.fontSize.bodyEmpLarge, + "--font-size-inline-code": vars.fontSize.bodySmall, + "--color-base": vars.colors.background.plain, } as React.CSSProperties; return { rootStyle }; diff --git a/src/components/GraphiQLPlain/GraphiQL.tsx b/src/components/GraphiQLPlain/GraphiQL.tsx new file mode 100644 index 000000000..93f62ea93 --- /dev/null +++ b/src/components/GraphiQLPlain/GraphiQL.tsx @@ -0,0 +1,808 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + Button, + ButtonGroup, + ChevronDownIcon, + ChevronUpIcon, + CopyIcon, + Dialog, + ExecuteButton, + GraphiQLProvider, + GraphiQLProviderProps, + HeaderEditor, + MergeIcon, + PlusIcon, + PrettifyIcon, + QueryEditor, + ReloadIcon, + ResponseEditor, + Spinner, + Tab, + Tabs, + ToolbarButton, + Tooltip, + UnStyledButton, + useCopyQuery, + useDragResize, + useEditorContext, + useExecutionContext, + UseHeaderEditorArgs, + useMergeQuery, + usePluginContext, + usePrettifyEditors, + UseQueryEditorArgs, + UseResponseEditorArgs, + useSchemaContext, + useStorageContext, + useTheme, + UseVariableEditorArgs, + VariableEditor, + WriteableEditorProps, +} from "@graphiql/react"; +import React, { + ComponentType, + PropsWithChildren, + ReactNode, + useState, +} from "react"; + +import { + useDashboardTheme, + useGraphiQLThemeSwitcher, +} from "../GraphiQL/styles"; + +export interface GraphiQLToolbarConfig { + /** + * This content will be rendered after the built-in buttons of the toolbar. + * Note that this will not apply if you provide a completely custom toolbar + * (by passing `GraphiQL.Toolbar` as child to the `GraphiQL` component). + */ + additionalContent?: React.ReactNode; +} + +/** + * API docs for this live here: + * + * https://graphiql-test.netlify.app/typedoc/modules/graphiql.html#graphiqlprops + */ +export type GraphiQLProps = Omit & + GraphiQLInterfaceProps; + +/** + * The top-level React component for GraphiQL, intended to encompass the entire + * browser viewport. + * + * @see https://github.com/graphql/graphiql#usage + */ + +export function GraphiQL({ + dangerouslyAssumeSchemaIsValid, + defaultQuery, + defaultTabs, + externalFragments, + fetcher, + getDefaultFieldNames, + headers, + initialTabs, + inputValueDeprecation, + introspectionQueryName, + maxHistoryLength, + onEditOperationName, + onSchemaChange, + onTabChange, + onTogglePluginVisibility, + operationName, + plugins, + query, + response, + schema, + schemaDescription, + shouldPersistHeaders, + storage, + validationRules, + variables, + visiblePlugin, + defaultHeaders, + ...props +}: GraphiQLProps) { + // Ensure props are correct + if (typeof fetcher !== "function") { + throw new TypeError( + "The `GraphiQL` component requires a `fetcher` function to be passed as prop.", + ); + } + + return ( + + + + ); +} +GraphiQL.Toolbar = GraphiQLToolbar; +GraphiQL.Footer = GraphiQLFooter; + +type AddSuffix, Suffix extends string> = { + [Key in keyof Obj as `${string & Key}${Suffix}`]: Obj[Key]; +}; + +export type GraphiQLInterfaceProps = WriteableEditorProps & + AddSuffix, "Query"> & + Pick & + AddSuffix, "Variables"> & + AddSuffix, "Headers"> & + Pick & { + children?: ReactNode; + /** + * Set the default state for the editor tools. + * - `false` hides the editor tools + * - `true` shows the editor tools + * - `'variables'` specifically shows the variables editor + * - `'headers'` specifically shows the headers editor + * By default the editor tools are initially shown when at least one of the + * editors has contents. + */ + defaultEditorToolsVisibility?: boolean | "variables" | "headers"; + /** + * Toggle if the headers editor should be shown inside the editor tools. + * @default true + */ + isHeadersEditorEnabled?: boolean; + /** + * An object that allows configuration of the toolbar next to the query + * editor. + */ + toolbar?: GraphiQLToolbarConfig; + /** + * Indicates if settings for persisting headers should appear in the + * settings modal. + */ + showPersistHeadersSettings?: boolean; + }; + +export function GraphiQLInterface(props: GraphiQLInterfaceProps) { + const isHeadersEditorEnabled = props.isHeadersEditorEnabled ?? true; + + const editorContext = useEditorContext({ nonNull: true }); + const executionContext = useExecutionContext({ nonNull: true }); + const schemaContext = useSchemaContext({ nonNull: true }); + const storageContext = useStorageContext(); + const pluginContext = usePluginContext(); + + const copy = useCopyQuery({ onCopyQuery: props.onCopyQuery }); + const merge = useMergeQuery(); + const prettify = usePrettifyEditors(); + const { rootStyle } = useDashboardTheme(); + + useGraphiQLThemeSwitcher(); + + const { theme, setTheme } = useTheme(); + + const PluginContent = pluginContext?.visiblePlugin?.content; + + const pluginResize = useDragResize({ + defaultSizeRelation: 1 / 3, + direction: "horizontal", + initiallyHidden: pluginContext?.visiblePlugin ? undefined : "first", + sizeThresholdSecond: 200, + storageKey: "docExplorerFlex", + }); + const editorResize = useDragResize({ + direction: "horizontal", + storageKey: "editorFlex", + }); + const editorToolsResize = useDragResize({ + defaultSizeRelation: 3, + direction: "vertical", + sizeThresholdSecond: 60, + storageKey: "secondaryEditorFlex", + }); + + const [activeSecondaryEditor, setActiveSecondaryEditor] = useState< + "variables" | "headers" + >(() => { + if ( + props.defaultEditorToolsVisibility === "variables" || + props.defaultEditorToolsVisibility === "headers" + ) { + return props.defaultEditorToolsVisibility; + } + return !editorContext.initialVariables && + editorContext.initialHeaders && + isHeadersEditorEnabled + ? "headers" + : "variables"; + }); + const [showDialog, setShowDialog] = useState< + "settings" | "short-keys" | null + >(null); + const [clearStorageStatus, setClearStorageStatus] = useState< + "success" | "error" | null + >(null); + + const children = React.Children.toArray(props.children); + + const toolbar = children.find(child => + isChildComponentType(child, GraphiQL.Toolbar), + ) || ( + <> + prettify()} + label="Prettify query (Shift-Ctrl-P)" + > + + merge()} + label="Merge fragments into query (Shift-Ctrl-M)" + > + + copy()} label="Copy query (Shift-Ctrl-C)"> + + {props.toolbar?.additionalContent || null} + + ); + + const footer = children.find(child => + isChildComponentType(child, GraphiQL.Footer), + ); + + const onClickReference = () => { + if (pluginResize.hiddenElement === "first") { + pluginResize.setHiddenElement(null); + } + }; + + const modifier = + window.navigator.platform.toLowerCase().indexOf("mac") === 0 ? ( + Cmd + ) : ( + Ctrl + ); + + return ( +
+
+
+ {pluginContext?.plugins.map(plugin => { + const isVisible = plugin === pluginContext.visiblePlugin; + const label = `${isVisible ? "Hide" : "Show"} ${plugin.title}`; + const Icon = plugin.icon; + return ( + + { + if (isVisible) { + pluginContext.setVisiblePlugin(null); + pluginResize.setHiddenElement("first"); + } else { + pluginContext.setVisiblePlugin(plugin); + pluginResize.setHiddenElement(null); + } + }} + aria-label={label} + > + + + ); + })} +
+
+ + schemaContext.introspect()} + aria-label="Re-fetch GraphQL schema" + > + + +
+
+
+
+
+ {PluginContent ? : null} +
+
+
+ {pluginContext?.visiblePlugin ? ( +
+ ) : null} +
+
+
+
+ + {editorContext.tabs.length > 1 ? ( + <> + {editorContext.tabs.map((tab, index) => ( + + { + executionContext.stop(); + editorContext.changeTab(index); + }} + > + {tab.title} + + { + if (editorContext.activeTabIndex === index) { + executionContext.stop(); + } + editorContext.closeTab(index); + }} + /> + + ))} +
+ + editorContext.addTab()} + aria-label="Add tab" + > + + +
+ + ) : null} +
+
+ {editorContext.tabs.length === 1 ? ( +
+ + editorContext.addTab()} + aria-label="Add tab" + > + + +
+ ) : null} +
+
+
+
+
+
+
+
+ +
+
+ + {toolbar} +
+
+
+
+
+
+ { + if (editorToolsResize.hiddenElement === "second") { + editorToolsResize.setHiddenElement(null); + } + setActiveSecondaryEditor("variables"); + }} + > + Variables + + {isHeadersEditorEnabled ? ( + { + if ( + editorToolsResize.hiddenElement === "second" + ) { + editorToolsResize.setHiddenElement(null); + } + setActiveSecondaryEditor("headers"); + }} + > + Headers + + ) : null} +
+ + { + editorToolsResize.setHiddenElement( + editorToolsResize.hiddenElement === "second" + ? null + : "second", + ); + }} + aria-label={ + editorToolsResize.hiddenElement === "second" + ? "Show editor tools" + : "Hide editor tools" + } + > + {editorToolsResize.hiddenElement === "second" ? ( + + +
+
+
+
+ + {isHeadersEditorEnabled && ( + + )} +
+
+
+
+
+
+
+
+
+ {executionContext.isFetching ? : null} + + {footer} +
+
+
+
+
+
+ setShowDialog(null)} + > +
+
Short Keys
+ setShowDialog(null)} /> +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Short keyFunction
+ {modifier} + {" + "} + F + Search in editor
+ {modifier} + {" + "} + K + Search in documentation
+ {modifier} + {" + "} + Enter + Execute query
+ Ctrl + {" + "} + Shift + {" + "} + P + Prettify editors
+ Ctrl + {" + "} + Shift + {" + "} + M + Merge fragments definitions into operation definition
+ Ctrl + {" + "} + Shift + {" + "} + C + Copy query
+ Ctrl + {" + "} + Shift + {" + "} + R + Re-fetch schema using introspection
+

+ The editors use{" "} + + CodeMirror Key Maps + {" "} + that add more short keys. This instance of GraphiQL uses{" "} + {props.keyMap || "sublime"}. +

+
+
+
+ { + setShowDialog(null); + setClearStorageStatus(null); + }} + > +
+
Settings
+ { + setShowDialog(null); + setClearStorageStatus(null); + }} + /> +
+
+
+
Theme
+
+ Adjust how the interface looks like. +
+
+
+ + + + + +
+
+ {storageContext ? ( +
+
+
Clear storage
+
+ Remove all locally stored data and start fresh. +
+
+
+ +
+
+ ) : null} +
+
+ ); +} + +// Configure the UI by providing this Component as a child of GraphiQL. +function GraphiQLToolbar(props: PropsWithChildren) { + return <>{props.children}; +} + +GraphiQLToolbar.displayName = "GraphiQLToolbar"; + +// Configure the UI by providing this Component as a child of GraphiQL. +function GraphiQLFooter(props: PropsWithChildren) { + return
{props.children}
; +} + +GraphiQLFooter.displayName = "GraphiQLFooter"; + +// Determines if the React child is of the same type of the provided React component +function isChildComponentType( + child: any, + component: T, +): child is T { + if ( + child?.type?.displayName && + child.type.displayName === component.displayName + ) { + return true; + } + + return child.type === component; +} + +export default GraphiQL; diff --git a/src/components/GraphiQLPlain/index.ts b/src/components/GraphiQLPlain/index.ts new file mode 100644 index 000000000..61a2335a9 --- /dev/null +++ b/src/components/GraphiQLPlain/index.ts @@ -0,0 +1,2 @@ +export { default } from "./GraphiQL"; +export * from "./GraphiQL"; diff --git a/src/index.tsx b/src/index.tsx index a1a936026..9f96f48a6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -33,6 +33,7 @@ import useAppChannel, { AppChannelProvider, } from "./components/AppLayout/AppChannelContext"; import { DateProvider } from "./components/Date"; +import { DevModeProvider } from "./components/DevModePanel/DevModeProvider"; import ErrorPage from "./components/ErrorPage"; import ExitFormDialogProvider from "./components/Form/ExitFormDialogProvider"; import { LocaleProvider } from "./components/Locale"; @@ -120,7 +121,9 @@ const App: React.FC = () => ( - + + + diff --git a/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx b/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx index 4c3daeb9d..23b1e6b1a 100644 --- a/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx +++ b/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx @@ -6,6 +6,7 @@ import { import { TopNav } from "@dashboard/components/AppLayout/TopNav"; import CardMenu from "@dashboard/components/CardMenu"; import { CardSpacer } from "@dashboard/components/CardSpacer"; +import { useDevModeContext } from "@dashboard/components/DevModePanel/hooks"; import Form from "@dashboard/components/Form"; import { DetailPageLayout } from "@dashboard/components/Layouts"; import Metadata, { MetadataFormData } from "@dashboard/components/Metadata"; @@ -22,7 +23,6 @@ import useNavigator from "@dashboard/hooks/useNavigator"; import { defaultGraphiQLQuery } from "@dashboard/orders/queries"; import { OrderErrorFragment, OrderSharedType } from "@dashboard/orders/types"; import { orderListUrl } from "@dashboard/orders/urls"; -import { playgroundOpenHandler } from "@dashboard/utils/graphql"; import { mapMetadataItemToInput } from "@dashboard/utils/maps"; import useMetadataChangeTrigger from "@dashboard/utils/metadata/useMetadataChangeTrigger"; import { ConfirmButtonTransitionState } from "@saleor/macaw-ui"; @@ -196,12 +196,13 @@ const OrderDetailsPage: React.FC = props => { order?.id, ); - const openPlaygroundURL = playgroundOpenHandler({ - query: defaultGraphiQLQuery, - headers: "", - operationName: "", - variables: `{ "id": "${order?.id}" }`, - }); + const context = useDevModeContext(); + + const openPlaygroundURL = () => { + context.setDevModeContent(defaultGraphiQLQuery); + context.setVariables(`{ "id": "${order?.id}" }`); + context.setDevModeVisibility(true); + }; return (
diff --git a/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx b/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx index 7ce66a28f..5f5a7d9b2 100644 --- a/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx +++ b/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx @@ -14,6 +14,7 @@ import Attributes, { AttributeInput } from "@dashboard/components/Attributes"; import CardMenu from "@dashboard/components/CardMenu"; import CardSpacer from "@dashboard/components/CardSpacer"; import ChannelsAvailabilityCard from "@dashboard/components/ChannelsAvailabilityCard"; +import { useDevModeContext } from "@dashboard/components/DevModePanel/hooks"; import { DetailPageLayout } from "@dashboard/components/Layouts"; import Metadata from "@dashboard/components/Metadata/Metadata"; import Savebar from "@dashboard/components/Savebar"; @@ -46,7 +47,6 @@ import { productImageUrl, productListUrl } from "@dashboard/products/urls"; import { ProductVariantListError } from "@dashboard/products/views/ProductUpdate/handlers/errors"; import { UseProductUpdateHandlerError } from "@dashboard/products/views/ProductUpdate/handlers/useProductUpdateHandler"; import { FetchMoreProps, RelayToFlat } from "@dashboard/types"; -import { playgroundOpenHandler } from "@dashboard/utils/graphql"; import { ConfirmButtonTransitionState } from "@saleor/macaw-ui"; import React from "react"; import { useIntl } from "react-intl"; @@ -242,12 +242,13 @@ export const ProductUpdatePage: React.FC = ({ productId, ); - const openPlaygroundURL = playgroundOpenHandler({ - query: defaultGraphiQLQuery, - headers: "", - operationName: "", - variables: `{ "id": "${product?.id}" }`, - }); + const context = useDevModeContext(); + + const openPlaygroundURL = () => { + context.setDevModeContent(defaultGraphiQLQuery); + context.setVariables(`{ "id": "${product?.id}" }`); + context.setDevModeVisibility(true); + }; return (