Add GraphiQL for defining subscription queries for webhooks (#2885)

Co-authored-by: Bartłomiej Wiaduch <tukan2can@gmail.com>
Co-authored-by: timur <timuric@gmail.com>
Co-authored-by: Michał Droń <dron.official@yahoo.com>
This commit is contained in:
Jakub Neander 2023-01-24 14:59:14 +01:00 committed by GitHub
parent 93df011c7b
commit 112747a51c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 2603 additions and 338 deletions

View file

@ -1,4 +1,3 @@
types/ types/
schema.graphql schema.graphql
*.generated.* *.generated.*
src/graphql/extendedTypes.ts

View file

@ -17,6 +17,7 @@ All notable, unreleased changes to this project will be documented in this file.
- Enable save button on page pages - #2325 by @orzechdev - Enable save button on page pages - #2325 by @orzechdev
- Fix pagination errors on voucher and sale pages - #2317 by @orzechdev - Fix pagination errors on voucher and sale pages - #2317 by @orzechdev
- Add format tip for text attribute rows - #2340 by @orzechdev - Add format tip for text attribute rows - #2340 by @orzechdev
- Add GraphiQL editor to webhook form for defining the subscription query #2885 by @2can @zaiste
## 3.4 ## 3.4

View file

@ -394,10 +394,6 @@
"context": "delete custom app", "context": "delete custom app",
"string": "Deleting this app, you will delete all the data and webhooks regarding this app." "string": "Deleting this app, you will delete all the data and webhooks regarding this app."
}, },
"1+M/52": {
"context": "section header",
"string": "Webhook Status"
},
"1/oG76": { "1/oG76": {
"context": "dialog header", "context": "dialog header",
"string": "Delete Sale" "string": "Delete Sale"
@ -422,6 +418,10 @@
"context": "weeks after label", "context": "weeks after label",
"string": "weeks after issue" "string": "weeks after issue"
}, },
"16Dpgb": {
"context": "Synchronous events description",
"string": "Synchronous webhook sends payload and waits for a response from the target URL to continue processing."
},
"16PGt9": { "16PGt9": {
"context": "WarehouseSettings all warehouses label", "context": "WarehouseSettings all warehouses label",
"string": "All warehouses" "string": "All warehouses"
@ -581,6 +581,10 @@
"context": "search modal shipping title", "context": "search modal shipping title",
"string": "Shipping address" "string": "Shipping address"
}, },
"2RYRdr": {
"context": "webhooks active label",
"string": "Active"
},
"2Sx05f": { "2Sx05f": {
"context": "Previous discount label", "context": "Previous discount label",
"string": "Previous discount value" "string": "Previous discount value"
@ -1427,10 +1431,6 @@
"9YazHG": { "9YazHG": {
"string": "Company" "string": "Company"
}, },
"9Yhddc": {
"context": "input label",
"string": "Registered events"
},
"9Zlogd": { "9Zlogd": {
"context": "staff member status", "context": "staff member status",
"string": "Active" "string": "Active"
@ -2160,6 +2160,10 @@
"context": "settings menu item", "context": "settings menu item",
"string": "Settings" "string": "Settings"
}, },
"F6LHyk": {
"context": "Webhook details objects",
"string": "Objects"
},
"F8gsds": { "F8gsds": {
"context": "unpublish page, button", "context": "unpublish page, button",
"string": "Unpublish" "string": "Unpublish"
@ -2355,14 +2359,14 @@
"context": "billing address", "context": "billing address",
"string": "Same as shipping address" "string": "Same as shipping address"
}, },
"GLewww": {
"context": "section header",
"string": "Events"
},
"GLy2UR": { "GLy2UR": {
"context": "order history message", "context": "order history message",
"string": "Fulfillment was cancelled" "string": "Fulfillment was cancelled"
}, },
"GNKG74": {
"context": "webhooks inactive label",
"string": "Inactive"
},
"GOdq5V": { "GOdq5V": {
"string": "Catalog" "string": "Catalog"
}, },
@ -2615,10 +2619,6 @@
"context": "PageTypeDeleteWarningDialog single assigned items button label", "context": "PageTypeDeleteWarningDialog single assigned items button label",
"string": "View pages" "string": "View pages"
}, },
"IBCBi1": {
"context": "webhook active description",
"string": "If you want to disable this webhook please uncheck the box below."
},
"IBw72y": { "IBw72y": {
"context": "switch button", "context": "switch button",
"string": "Is this product shippable?" "string": "Is this product shippable?"
@ -3687,10 +3687,6 @@
"context": "button", "context": "button",
"string": "Delete Search" "string": "Delete Search"
}, },
"QEvH8Q": {
"context": "section description",
"string": "Assign permissions to register asynchronous events for this webhook."
},
"QFBjlV": { "QFBjlV": {
"context": "error message", "context": "error message",
"string": "Stock for this warehouse already exists for this product variant" "string": "Stock for this warehouse already exists for this product variant"
@ -3796,6 +3792,10 @@
"context": "label for radio button", "context": "label for radio button",
"string": "Product prices are entered without tax" "string": "Product prices are entered without tax"
}, },
"QpSQ5w": {
"context": "Webhook subscription query card title",
"string": "Payload Query"
},
"Qph0GE": { "Qph0GE": {
"context": "dialog content", "context": "dialog content",
"string": "Add a new address:" "string": "Add a new address:"
@ -4135,10 +4135,6 @@
"context": "product inventory, checkbox", "context": "product inventory, checkbox",
"string": "Track Inventory" "string": "Track Inventory"
}, },
"TjbB4Y": {
"context": "section subheader",
"string": "Asynchronous events"
},
"Tl+7X4": { "Tl+7X4": {
"context": "tabel column header", "context": "tabel column header",
"string": "Refunded Qty" "string": "Refunded Qty"
@ -4460,10 +4456,6 @@
"context": "button label", "context": "button label",
"string": "Deactivate" "string": "Deactivate"
}, },
"W+x5ZI": {
"context": "webhooks active label",
"string": "Webhook is active"
},
"W/Es0H": { "W/Es0H": {
"string": "Order successfully cancelled" "string": "Order successfully cancelled"
}, },
@ -5367,6 +5359,10 @@
"context": "empty metadata text", "context": "empty metadata text",
"string": "No metadata created for this element. Use the button below to add new metadata field." "string": "No metadata created for this element. Use the button below to add new metadata field."
}, },
"cZN5Jd": {
"context": "Webhook details events",
"string": "Events"
},
"caMMWN": { "caMMWN": {
"string": "Search Warehouse" "string": "Search Warehouse"
}, },
@ -5470,10 +5466,6 @@
"dPYqy0": { "dPYqy0": {
"string": "We are currently exporting your requested CSV. As soon as it is available it will be sent to your email address" "string": "We are currently exporting your requested CSV. As soon as it is available it will be sent to your email address"
}, },
"dQdxLT": {
"context": "section subheader",
"string": "Synchronous events"
},
"dS+SOT": { "dS+SOT": {
"context": "header", "context": "header",
"string": "Column settings" "string": "Column settings"
@ -5617,10 +5609,6 @@
"context": "gift card history message", "context": "gift card history message",
"string": "Gift card was activated by {activatedBy}" "string": "Gift card was activated by {activatedBy}"
}, },
"fHopox": {
"context": "section description",
"string": "Assign permissions to register synchronous events for this webhook."
},
"fLhj3a": { "fLhj3a": {
"context": "gift card history message", "context": "gift card history message",
"string": "Gift card expiry date was updated" "string": "Gift card expiry date was updated"
@ -6462,6 +6450,10 @@
"context": "taxes title", "context": "taxes title",
"string": "Taxes (VAT included)" "string": "Taxes (VAT included)"
}, },
"mSCZd4": {
"context": "Webhook details asynchronous events",
"string": "Asynchronous"
},
"mSLr9d": { "mSLr9d": {
"context": "voucher code, button", "context": "voucher code, button",
"string": "Generate Code" "string": "Generate Code"
@ -7922,6 +7914,10 @@
"context": "button", "context": "button",
"string": "Create Voucher" "string": "Create Voucher"
}, },
"yAFaVK": {
"context": "Webhook details synchronous events",
"string": "Synchronous"
},
"yDkmX7": { "yDkmX7": {
"context": "dialog content", "context": "dialog content",
"string": "{counter,plural,one{Are you sure you want to delete this product?} other{Are you sure you want to delete {displayQuantity} products?}}" "string": "{counter,plural,one{Are you sure you want to delete this product?} other{Are you sure you want to delete {displayQuantity} products?}}"
@ -7944,6 +7940,10 @@
"context": "voucher uses", "context": "voucher uses",
"string": "Uses" "string": "Uses"
}, },
"yJqbYv": {
"context": "Asynchronous events description",
"string": "Asynchronous webhook sends payload and continues processing."
},
"yJynYK": { "yJynYK": {
"context": "discount value", "context": "discount value",
"string": "discount" "string": "discount"

1573
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,9 @@
"@editorjs/paragraph": "^2.8.0", "@editorjs/paragraph": "^2.8.0",
"@editorjs/quote": "^2.4.0", "@editorjs/quote": "^2.4.0",
"@glideapps/glide-data-grid": "^5.0.0", "@glideapps/glide-data-grid": "^5.0.0",
"@graphiql/plugin-explorer": "^0.1.12",
"@graphiql/react": "^0.15.0",
"@graphiql/toolkit": "^0.8.0",
"@material-ui/core": "^4.12.4", "@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.2", "@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.61", "@material-ui/lab": "^4.0.0-alpha.61",
@ -50,6 +53,7 @@
"find-test-names": "^1.17.1", "find-test-names": "^1.17.1",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"fuzzaldrin": "^2.1.0", "fuzzaldrin": "^2.1.0",
"graphiql": "^2.2.0",
"graphql": "^15.4.0", "graphql": "^15.4.0",
"hotkeys-js": "^3.8.1", "hotkeys-js": "^3.8.1",
"is-url": "^1.2.4", "is-url": "^1.2.4",
@ -207,7 +211,7 @@
"mochawesome": "^7.0.1", "mochawesome": "^7.0.1",
"mochawesome-merge": "^4.2.1", "mochawesome-merge": "^4.2.1",
"mochawesome-report-generator": "^6.0.1", "mochawesome-report-generator": "^6.0.1",
"prettier": "^1.19.1", "prettier": "^2.8.3",
"setup-polly-jest": "^0.9.1", "setup-polly-jest": "^0.9.1",
"ts-jest": "^27.1.5", "ts-jest": "^27.1.5",
"jest-environment-jsdom": "^27.5.1" "jest-environment-jsdom": "^27.5.1"

View file

@ -0,0 +1,298 @@
import {
CopyIcon,
GraphiQLProvider,
GraphiQLProviderProps,
PrettifyIcon,
QueryEditor,
ToolbarButton,
Tooltip,
UnStyledButton,
useCopyQuery,
useEditorContext,
UseHeaderEditorArgs,
usePluginContext,
usePrettifyEditors,
UseQueryEditorArgs,
UseResponseEditorArgs,
UseVariableEditorArgs,
WriteableEditorProps,
} from "@graphiql/react";
import clsx from "clsx";
import React, { ComponentType, PropsWithChildren, ReactNode } from "react";
import {
useDashboardTheme,
useEditorStyles,
useGraphiQLThemeSwitcher,
useStyles,
} from "./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;
}
export type GraphiQLProps = Omit<GraphiQLProviderProps, "children"> &
GraphiQLInterfaceProps;
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 (
<GraphiQLProvider
getDefaultFieldNames={getDefaultFieldNames}
dangerouslyAssumeSchemaIsValid={dangerouslyAssumeSchemaIsValid}
defaultQuery={defaultQuery}
defaultHeaders={defaultHeaders}
defaultTabs={defaultTabs}
externalFragments={externalFragments}
fetcher={fetcher}
headers={headers}
initialTabs={initialTabs}
inputValueDeprecation={inputValueDeprecation}
introspectionQueryName={introspectionQueryName}
maxHistoryLength={maxHistoryLength}
onEditOperationName={onEditOperationName}
onSchemaChange={onSchemaChange}
onTabChange={onTabChange}
onTogglePluginVisibility={onTogglePluginVisibility}
plugins={plugins}
visiblePlugin={visiblePlugin}
operationName={operationName}
query={query}
response={response}
schema={schema}
schemaDescription={schemaDescription}
shouldPersistHeaders={shouldPersistHeaders}
storage={storage}
validationRules={validationRules}
variables={variables}
>
<GraphiQLInterface {...props} />
</GraphiQLProvider>
);
}
// Export main windows/panes to be used separately if desired.
GraphiQL.Toolbar = GraphiQLToolbar;
type AddSuffix<Obj extends Record<string, any>, Suffix extends string> = {
[Key in keyof Obj as `${string & Key}${Suffix}`]: Obj[Key];
};
export type GraphiQLInterfaceProps = WriteableEditorProps &
AddSuffix<Pick<UseQueryEditorArgs, "onEdit">, "Query"> &
Pick<UseQueryEditorArgs, "onCopyQuery"> &
AddSuffix<Pick<UseVariableEditorArgs, "onEdit">, "Variables"> &
AddSuffix<Pick<UseHeaderEditorArgs, "onEdit">, "Headers"> &
Pick<UseResponseEditorArgs, "responseTooltip"> & {
children?: ReactNode;
defaultEditorToolsVisibility?: boolean | "variables" | "headers";
isHeadersEditorEnabled?: boolean;
toolbar?: GraphiQLToolbarConfig;
};
export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
const editorContext = useEditorContext({ nonNull: true });
const pluginContext = usePluginContext();
const classes = useStyles();
const { pluginResize, editorResize, editorToolsResize } = useEditorStyles();
const copy = useCopyQuery({ onCopyQuery: props.onCopyQuery });
const prettify = usePrettifyEditors();
const { rootStyle } = useDashboardTheme();
useGraphiQLThemeSwitcher();
const PluginContent = pluginContext?.visiblePlugin?.content;
const children = React.Children.toArray(props.children);
const toolbar = children.find(child =>
isChildComponentType(child, GraphiQL.Toolbar),
) || (
<>
<ToolbarButton
onClick={() => prettify()}
label="Prettify query (Shift-Ctrl-P)"
>
<PrettifyIcon className="graphiql-toolbar-icon" aria-hidden="true" />
</ToolbarButton>
<ToolbarButton onClick={() => copy()} label="Copy query (Shift-Ctrl-C)">
<CopyIcon className="graphiql-toolbar-icon" aria-hidden="true" />
</ToolbarButton>
{props.toolbar?.additionalContent || null}
</>
);
const onClickReference = () => {
if (pluginResize.hiddenElement === "first") {
pluginResize.setHiddenElement(null);
}
};
return (
<div
data-test-id="graphiql-container"
className="graphiql-container"
style={rootStyle}
>
<div className="graphiql-sidebar">
<div className="graphiql-sidebar-section">
{pluginContext?.plugins.map(plugin => {
const isVisible = plugin === pluginContext.visiblePlugin;
const label = `${isVisible ? "Hide" : "Show"} ${plugin.title}`;
const Icon = plugin.icon;
return (
<Tooltip key={plugin.title} label={label}>
<UnStyledButton
type="button"
className={isVisible ? "active" : ""}
onClick={() => {
if (isVisible) {
pluginContext.setVisiblePlugin(null);
pluginResize.setHiddenElement("first");
} else {
pluginContext.setVisiblePlugin(plugin);
pluginResize.setHiddenElement(null);
}
}}
aria-label={label}
>
<Icon aria-hidden="true" />
</UnStyledButton>
</Tooltip>
);
})}
</div>
<div className="graphiql-sidebar-section"></div>
</div>
<div className="graphiql-main">
<div
ref={pluginResize.firstRef}
style={{
// Make sure the container shrinks when containing long
// non-breaking texts
minWidth: "200px",
}}
>
<div className={clsx("graphiql-plugin", classes.scrollable)}>
{PluginContent ? <PluginContent /> : null}
</div>
</div>
<div ref={pluginResize.dragBarRef}>
{pluginContext?.visiblePlugin ? (
<div className="graphiql-horizontal-drag-bar" />
) : null}
</div>
<div ref={pluginResize.secondRef} style={{ minWidth: 0 }}>
<div className="graphiql-sessions">
<div
role="tabpanel"
id="graphiql-session"
className="graphiql-session"
style={{ padding: "2rem 0 0 0" }}
aria-labelledby={`graphiql-session-tab-${editorContext.activeTabIndex}`}
>
<div ref={editorResize.firstRef}>
<div
className="graphiql-editors full-height"
style={{ boxShadow: "none" }}
>
<div ref={editorToolsResize.firstRef}>
<section
className="graphiql-query-editor"
aria-label="Query Editor"
style={{ borderBottom: 0 }}
>
<div
className="graphiql-query-editor-wrapper"
style={{ fontSize: "1.6rem" }}
>
<QueryEditor
editorTheme={props.editorTheme}
keyMap={props.keyMap}
onClickReference={onClickReference}
onCopyQuery={props.onCopyQuery}
onEdit={props.onEditQuery}
readOnly={props.readOnly}
/>
</div>
<div
className="graphiql-toolbar"
role="toolbar"
aria-label="Editor Commands"
>
{toolbar}
</div>
</section>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function GraphiQLToolbar<TProps>(props: PropsWithChildren<TProps>) {
return <>{props.children}</>;
}
GraphiQLToolbar.displayName = "GraphiQLToolbar";
function isChildComponentType<T extends ComponentType>(
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;

View file

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

View file

@ -0,0 +1,75 @@
import {
useDragResize,
usePluginContext,
useTheme as useGraphiQLTheme,
} from "@graphiql/react";
import { makeStyles, useTheme } from "@saleor/macaw-ui";
import { useEffect } from "react";
export const useStyles = makeStyles(
() => ({
scrollable: {
// Overrides inline styling which breaks scroll
// on doc explorer plugin
"& > :first-child": {
overflowY: "scroll !important",
},
},
}),
{ name: "GraphiQL" },
);
export const useEditorStyles = () => {
const pluginContext = usePluginContext();
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",
});
return {
pluginResize,
editorResize,
editorToolsResize,
};
};
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,
} as React.CSSProperties;
return { rootStyle };
};
export const useGraphiQLThemeSwitcher = () => {
const theme = useTheme();
const { theme: graphiqlTheme, setTheme: setGraphiqlTheme } =
useGraphiQLTheme();
useEffect(() => {
if (theme.themeType !== graphiqlTheme) {
setGraphiqlTheme(theme.themeType);
}
});
};

View file

@ -2,7 +2,6 @@ import { Backlink } from "@dashboard/components/Backlink";
import Container from "@dashboard/components/Container"; import Container from "@dashboard/components/Container";
import Form from "@dashboard/components/Form"; import Form from "@dashboard/components/Form";
import FormSpacer from "@dashboard/components/FormSpacer"; import FormSpacer from "@dashboard/components/FormSpacer";
import Grid from "@dashboard/components/Grid";
import PageHeader from "@dashboard/components/PageHeader"; import PageHeader from "@dashboard/components/PageHeader";
import Savebar from "@dashboard/components/Savebar"; import Savebar from "@dashboard/components/Savebar";
import WebhookEvents from "@dashboard/custom-apps/components/WebhookEvents"; import WebhookEvents from "@dashboard/custom-apps/components/WebhookEvents";
@ -13,10 +12,6 @@ import {
createSyncEventsSelectHandler, createSyncEventsSelectHandler,
} from "@dashboard/custom-apps/handlers"; } from "@dashboard/custom-apps/handlers";
import { CustomAppUrls } from "@dashboard/custom-apps/urls"; import { CustomAppUrls } from "@dashboard/custom-apps/urls";
import {
mapAsyncEventsToChoices,
mapSyncEventsToChoices,
} from "@dashboard/custom-apps/utils";
import { import {
WebhookDetailsFragment, WebhookDetailsFragment,
WebhookErrorFragment, WebhookErrorFragment,
@ -26,9 +21,11 @@ import {
import { SubmitPromise } from "@dashboard/hooks/useForm"; import { SubmitPromise } from "@dashboard/hooks/useForm";
import useNavigator from "@dashboard/hooks/useNavigator"; import useNavigator from "@dashboard/hooks/useNavigator";
import { ConfirmButtonTransitionState } from "@saleor/macaw-ui"; import { ConfirmButtonTransitionState } from "@saleor/macaw-ui";
import React from "react"; import { parse, print } from "graphql";
import React, { useEffect, useState } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import WebhookSubscriptionQuery from "../WebhookSubscriptionQuery/WebhookSubscriptionQuery";
import { getHeaderTitle } from "./messages"; import { getHeaderTitle } from "./messages";
export interface WebhookFormData { export interface WebhookFormData {
@ -36,8 +33,9 @@ export interface WebhookFormData {
asyncEvents: WebhookEventTypeAsyncEnum[]; asyncEvents: WebhookEventTypeAsyncEnum[];
isActive: boolean; isActive: boolean;
name: string; name: string;
secretKey: string | null; secretKey?: string;
targetUrl: string; targetUrl: string;
subscriptionQuery: string;
} }
export interface WebhookDetailsPageProps { export interface WebhookDetailsPageProps {
@ -62,6 +60,13 @@ const WebhookDetailsPage: React.FC<WebhookDetailsPageProps> = ({
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigator(); const navigate = useNavigator();
let prettified: string;
try {
prettified = print(parse(webhook?.subscriptionQuery));
} catch {
prettified = webhook?.subscriptionQuery || "";
}
const initialForm: WebhookFormData = { const initialForm: WebhookFormData = {
syncEvents: webhook?.syncEvents?.map(event => event.eventType) || [], syncEvents: webhook?.syncEvents?.map(event => event.eventType) || [],
asyncEvents: webhook?.asyncEvents?.map(event => event.eventType) || [], asyncEvents: webhook?.asyncEvents?.map(event => event.eventType) || [],
@ -69,61 +74,64 @@ const WebhookDetailsPage: React.FC<WebhookDetailsPageProps> = ({
name: webhook?.name || "", name: webhook?.name || "",
secretKey: webhook?.secretKey || "", secretKey: webhook?.secretKey || "",
targetUrl: webhook?.targetUrl || "", targetUrl: webhook?.targetUrl || "",
subscriptionQuery: prettified || "",
}; };
const backUrl = CustomAppUrls.resolveAppUrl(appId); const backUrl = CustomAppUrls.resolveAppUrl(appId);
return ( const [query, setQuery] = useState(prettified);
<Form confirmLeave initial={initialForm} onSubmit={onSubmit}>
{({ data, submit, change }) => {
const syncEventsChoices = disabled
? []
: mapSyncEventsToChoices(Object.values(WebhookEventTypeSyncEnum));
const asyncEventsChoices = disabled
? []
: mapAsyncEventsToChoices(
Object.values(WebhookEventTypeAsyncEnum),
data.asyncEvents,
);
useEffect(() => {
setQuery(prettified);
}, [prettified]);
const handleSubmit = (data: WebhookFormData) => {
onSubmit({ ...data, ...{ subscriptionQuery: query } });
};
return (
<Form confirmLeave initial={initialForm} onSubmit={handleSubmit}>
{({ data, submit, change }) => {
const handleSyncEventsSelect = createSyncEventsSelectHandler( const handleSyncEventsSelect = createSyncEventsSelectHandler(
change, change,
data.syncEvents, data.syncEvents,
setQuery,
); );
const handleAsyncEventsSelect = createAsyncEventsSelectHandler( const handleAsyncEventsSelect = createAsyncEventsSelectHandler(
change, change,
data.asyncEvents, data.asyncEvents,
query,
setQuery,
); );
return ( return (
<Container> <Container>
<Backlink href={backUrl}>{appName}</Backlink> <Backlink href={backUrl}>{appName}</Backlink>
<PageHeader title={getHeaderTitle(intl, webhook)} /> <PageHeader title={getHeaderTitle(intl, webhook)}>
<Grid variant="uniform"> <WebhookStatus
<div> data={data.isActive}
<WebhookInfo disabled={disabled}
data={data} onChange={change}
disabled={disabled} />
errors={errors} </PageHeader>
onChange={change} <WebhookInfo
/> data={data}
</div> disabled={disabled}
<div> errors={errors}
<WebhookStatus onChange={change}
data={data.isActive} />
disabled={disabled} <FormSpacer />
onChange={change} <WebhookEvents
/> data={data}
<FormSpacer /> onSyncEventChange={handleSyncEventsSelect}
<WebhookEvents onAsyncEventChange={handleAsyncEventsSelect}
data={data} />
syncEventsChoices={syncEventsChoices} <FormSpacer />
asyncEventsChoices={asyncEventsChoices} <WebhookSubscriptionQuery
onSyncEventChange={handleSyncEventsSelect} query={query}
onAsyncEventChange={handleAsyncEventsSelect} setQuery={setQuery}
/> data={data}
</div> />
</Grid>
<Savebar <Savebar
disabled={disabled} disabled={disabled}
state={saveButtonBarState} state={saveButtonBarState}

View file

@ -1,97 +1,235 @@
import VerticalSpacer from "@dashboard/apps/components/VerticalSpacer"; import Grid from "@dashboard/components/Grid";
import CardTitle from "@dashboard/components/CardTitle";
import Hr from "@dashboard/components/Hr"; import Hr from "@dashboard/components/Hr";
import MultiAutocompleteSelectField, {
MultiAutocompleteChoiceType,
} from "@dashboard/components/MultiAutocompleteSelectField";
import {
mapAsyncEventsToChoices,
mapSyncEventsToChoices,
} from "@dashboard/custom-apps/utils";
import { import {
WebhookEventTypeAsyncEnum, WebhookEventTypeAsyncEnum,
WebhookEventTypeSyncEnum, WebhookEventTypeSyncEnum,
} from "@dashboard/graphql"; } from "@dashboard/graphql";
import { ChangeEvent } from "@dashboard/hooks/useForm"; import { ChangeEvent } from "@dashboard/hooks/useForm";
import { Card, CardContent, Typography } from "@material-ui/core"; import { capitalize } from "@dashboard/misc";
import React from "react"; import { Card, CardContent, Checkbox, Typography } from "@material-ui/core";
import {
List,
ListHeader,
ListItem,
ListItemCell,
PageTab,
PageTabPanel,
PageTabs,
Pill,
useListWidths,
} from "@saleor/macaw-ui";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { messages } from "./messages"; import { messages } from "./messages";
import { useStyles } from "./styles";
interface WebhookEventsProps { interface WebhookEventsProps {
data: { data: {
syncEvents: WebhookEventTypeSyncEnum[]; syncEvents: WebhookEventTypeSyncEnum[];
asyncEvents: WebhookEventTypeAsyncEnum[]; asyncEvents: WebhookEventTypeAsyncEnum[];
}; };
syncEventsChoices: MultiAutocompleteChoiceType[];
asyncEventsChoices: MultiAutocompleteChoiceType[];
onSyncEventChange: (event: ChangeEvent) => void; onSyncEventChange: (event: ChangeEvent) => void;
onAsyncEventChange: (event: ChangeEvent) => void; onAsyncEventChange: (event: ChangeEvent) => void;
} }
const WebhookEvents: React.FC<WebhookEventsProps> = ({ const WebhookEvents: React.FC<WebhookEventsProps> = ({
data, data,
syncEventsChoices,
asyncEventsChoices,
onSyncEventChange, onSyncEventChange,
onAsyncEventChange, onAsyncEventChange,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const classes = useStyles();
const { checkbox } = useListWidths();
const [tab, setTab] = useState("async");
const [object, setObject] = useState<string | null>(null);
const handleEventChange = event => {
if (tab === "sync") {
return onSyncEventChange(event);
}
return onAsyncEventChange(event);
};
const handleTabChange = value => {
setObject(null);
setTab(value);
};
const countEvents = object => {
const selected = tab === "sync" ? data.syncEvents : data.asyncEvents;
const objectEvents = EventTypes[tab][object].map(
event => `${object}_${event}`,
);
return objectEvents.filter((event: never) => selected.includes(event))
.length;
};
return ( return (
<Card> <>
<CardTitle title={intl.formatMessage(messages.events)} /> <Card>
<CardContent> <CardContent>
<Typography variant="caption"> <PageTabs value={tab} onChange={handleTabChange}>
<FormattedMessage {...messages.synchronousEvents} /> <PageTab
</Typography> label={intl.formatMessage(messages.asynchronous)}
<VerticalSpacer /> value="async"
<Typography variant="body1"> />
<FormattedMessage <PageTab
{...messages.assignPermissionsToSynchronousEvents} label={intl.formatMessage(messages.synchronous)}
/> value="sync"
</Typography> />
<VerticalSpacer /> </PageTabs>
<MultiAutocompleteSelectField
displayValues={mapSyncEventsToChoices(data.syncEvents)} <Typography variant="subtitle2" style={{ padding: "1rem 0" }}>
label={intl.formatMessage(messages.registeredEvents)} <PageTabPanel show={tab === "sync"}>
choices={syncEventsChoices} <FormattedMessage {...messages.synchronousDescription} />
name="syncEvents" </PageTabPanel>
value={data.syncEvents} <PageTabPanel show={tab === "async"}>
onChange={onSyncEventChange} <FormattedMessage {...messages.asynchronousDescription} />
data-test-id="syncEvents" </PageTabPanel>
testId="syncEvent" </Typography>
/> </CardContent>
<VerticalSpacer spacing={2} />
<Hr /> <Hr />
<VerticalSpacer spacing={2} /> <Grid variant="uniform">
<Typography variant="caption"> <div className={classes.objectsWrapper}>
<FormattedMessage {...messages.asynchronousEvents} /> <PageTabPanel show={true}>
</Typography> <List gridTemplate={["1fr 50px"]}>
<VerticalSpacer /> <ListHeader>
<Typography variant="body1"> <ListItem className={classes.listHeader}>
<FormattedMessage <ListItemCell className={classes.listItemCell}>
{...messages.assignPermissionsToAsynchronousEvents} <FormattedMessage {...messages.objects} />
/> </ListItemCell>
</Typography> <ListItemCell></ListItemCell>
<VerticalSpacer /> </ListItem>
<MultiAutocompleteSelectField </ListHeader>
displayValues={mapAsyncEventsToChoices( <div className={classes.listItems}>
data.asyncEvents, {Object.keys(EventTypes[tab]).map((object, idx) => (
data.asyncEvents, <ListItem
)} key={idx}
label={intl.formatMessage(messages.registeredEvents)} className={classes.listItem}
choices={asyncEventsChoices} onClick={() => setObject(object)}
name="asyncEvents" >
value={data.asyncEvents} <ListItemCell className={classes.listItemCell}>
onChange={onAsyncEventChange} <strong>
data-test-id="asyncEvents" {capitalize(
testId="asyncEvent" object.replaceAll("_", " ").toLowerCase(),
/> )}
</CardContent> </strong>
</Card> </ListItemCell>
<ListItemCell>
{countEvents(object) > 0 && (
<Pill
size="small"
color="error"
label={countEvents(object)}
/>
)}
</ListItemCell>
</ListItem>
))}
</div>
</List>
</PageTabPanel>
</div>
<div className={classes.eventsWrapper}>
<List gridTemplate={["1fr", checkbox]}>
<ListHeader>
<ListItem className={classes.listHeader}>
<ListItemCell className={classes.listItemCell}>
<FormattedMessage {...messages.events} />
</ListItemCell>
</ListItem>
</ListHeader>
{object &&
EventTypes[tab][object] &&
EventTypes[tab][object].map((event, idx) => (
<ListItem className={classes.listItem} key={idx}>
<ListItemCell className={classes.listItemCell}>
<strong>
{capitalize(event.toLowerCase().replaceAll("_", " "))}
</strong>
</ListItemCell>
<ListItemCell>
<Checkbox
name={`${tab}Events`}
checked={data[`${tab}Events`].includes(
getEventName(object, event),
)}
value={getEventName(object, event)}
onChange={handleEventChange}
className={classes.checkbox}
/>
</ListItemCell>
</ListItem>
))}
</List>
</div>
</Grid>
</Card>
</>
); );
}; };
WebhookEvents.displayName = "WebhookEvents"; WebhookEvents.displayName = "WebhookEvents";
export default WebhookEvents; export default WebhookEvents;
type Actions = string[];
const AsyncWebhookTypes: Record<string, Actions> = {
ADDRESS: ["CREATED", "UPDATED", "DELETED"],
APP: ["INSTALLED", "UPDATED", "DELETED"],
ATTRIBUTE: ["CREATED", "UPDATED", "DELETED"],
CATEGORY: ["CREATED", "UPDATED", "DELETED"],
CHANNEL: ["CREATED", "UPDATED", "DELETED"],
GIFT_CARD: ["CREATED", "UPDATED", "DELETED", "STATUS_CHANGED"],
CHECKOUT: ["CREATED", "UPDATED", "DELETED"],
COLLECTION: ["CREATED", "UPDATED", "DELETED"],
CUSTOMER: ["CREATED", "UPDATED", "DELETED"],
FULFILLMENT: ["CREATED"],
INVOICE: ["DELETED", "REQUESTED", "SENT"],
MENU: ["CREATED", "UPDATED", "DELETED"],
ORDER: [
"CANCELLED",
"CONFIRMED",
"CREATED",
"FULFILLED",
"FULLY_PAID",
"UPDATED",
],
PAGE: ["CREATED", "UPDATED", "DELETED"],
PRODUCT: ["CREATED", "UPDATED", "DELETED"],
PRODUCT_VARIANT: ["CREATED", "UPDATED", "DELETED"],
SALE: ["CREATED", "UPDATED", "DELETED", "TOGGLE"],
SHIPPING_PRICE: ["CREATED", "UPDATED", "DELETED"],
SHIPPING_ZONE: ["CREATED", "UPDATED", "DELETED"],
STAFF: ["CREATED", "UPDATED", "DELETED"],
TRANSLATION: ["ACTION_REQUEST", "CREATED", "UPDATED"],
VOUCHER: ["CREATED", "UPDATED", "DELETED"],
WAREHOUSE: ["CREATED", "UPDATED", "DELETED"],
};
const SyncWebhookTypes: Record<string, Actions> = {
PAYMENT: [
"AUTHORIZE",
"CAPTURE",
"CONFIRM",
"LIST_GATEWAYS",
"PROCESS",
"REFUND",
"VOID",
],
CHECKOUT: ["CALCULATE_TAXES", "FILTER_SHIPPING_METHODS"],
ORDER: ["CALCULATE_TAXES", "FILTER_SHIPPING_METHODS"],
SHIPPING: ["LIST_METHODS_FOR_CHECKOUT"],
};
const EventTypes = {
async: AsyncWebhookTypes,
sync: SyncWebhookTypes,
};
const getEventName = (object: string, event: string) =>
[object, event].join("_").toUpperCase() as WebhookEventTypeSyncEnum;

View file

@ -2,35 +2,35 @@ import { defineMessages } from "react-intl";
export const messages = defineMessages({ export const messages = defineMessages({
events: { events: {
id: "GLewww", id: "cZN5Jd",
defaultMessage: "Events", defaultMessage: "Events",
description: "section header", description: "Webhook details events",
}, },
synchronousEvents: { synchronous: {
id: "dQdxLT", id: "yAFaVK",
defaultMessage: "Synchronous events", defaultMessage: "Synchronous",
description: "section subheader", description: "Webhook details synchronous events",
}, },
asynchronousEvents: { asynchronous: {
id: "TjbB4Y", id: "mSCZd4",
defaultMessage: "Asynchronous events", defaultMessage: "Asynchronous",
description: "section subheader", description: "Webhook details asynchronous events",
}, },
assignPermissionsToSynchronousEvents: { synchronousDescription: {
id: "fHopox", id: "16Dpgb",
defaultMessage: defaultMessage:
"Assign permissions to register synchronous events for this webhook.", "Synchronous webhook sends payload and waits for a response from the target URL to continue processing.",
description: "section description", description: "Synchronous events description",
}, },
assignPermissionsToAsynchronousEvents: { asynchronousDescription: {
id: "QEvH8Q", id: "yJqbYv",
defaultMessage: defaultMessage:
"Assign permissions to register asynchronous events for this webhook.", "Asynchronous webhook sends payload and continues processing.",
description: "section description", description: "Asynchronous events description",
}, },
registeredEvents: { objects: {
id: "9Yhddc", defaultMessage: "Objects",
defaultMessage: "Registered events", id: "F6LHyk",
description: "input label", description: "Webhook details objects",
}, },
}); });

View file

@ -0,0 +1,37 @@
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles(
theme => ({
eventsWrapper: {
overflow: "scroll",
padding: theme.spacing(4),
paddingLeft: 0,
},
objectsWrapper: {
borderRight: "1px solid",
borderRightColor: theme.palette.divider,
padding: theme.spacing(3),
},
listHeader: {
textTransform: "uppercase",
padding: theme.spacing(1),
minHeight: 0,
},
listItem: {
minHeight: 0,
gap: 0,
padding: theme.spacing(1),
},
listItemCell: {
paddingLeft: "0 !important",
},
listItems: {
height: 300,
overflow: "scroll",
},
checkbox: {
padding: 0,
},
}),
{ name: "WebhookEvents" },
);

View file

@ -1,7 +1,5 @@
import CardTitle from "@dashboard/components/CardTitle"; import ControlledSwitch from "@dashboard/components/ControlledSwitch";
import ControlledCheckbox from "@dashboard/components/ControlledCheckbox";
import { ChangeEvent } from "@dashboard/hooks/useForm"; import { ChangeEvent } from "@dashboard/hooks/useForm";
import { Card, CardContent, Typography } from "@material-ui/core";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
@ -21,22 +19,19 @@ const WebhookStatus: React.FC<WebhookStatusProps> = ({
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
return ( return (
<Card> <ControlledSwitch
<CardTitle title={intl.formatMessage(messages.webhookStatus)} /> name={"isActive" as keyof WebhookFormData}
<CardContent> label={
<Typography variant="body1"> data
{intl.formatMessage(messages.webhookActiveDescription)} ? intl.formatMessage(messages.webhookActive)
</Typography> : intl.formatMessage(messages.webhookInactive)
<ControlledCheckbox }
name={"isActive" as keyof WebhookFormData} checked={data}
label={intl.formatMessage(messages.webhookActive)} onChange={onChange}
checked={data} disabled={disabled}
onChange={onChange} />
disabled={disabled}
/>
</CardContent>
</Card>
); );
}; };
WebhookStatus.displayName = "WebhookStatus"; WebhookStatus.displayName = "WebhookStatus";
export default WebhookStatus; export default WebhookStatus;

View file

@ -1,20 +1,14 @@
import { defineMessages } from "react-intl"; import { defineMessages } from "react-intl";
export const messages = defineMessages({ export const messages = defineMessages({
webhookStatus: {
id: "1+M/52",
defaultMessage: "Webhook Status",
description: "section header",
},
webhookActive: { webhookActive: {
id: "W+x5ZI", id: "2RYRdr",
defaultMessage: "Webhook is active", defaultMessage: "Active",
description: "webhooks active label", description: "webhooks active label",
}, },
webhookActiveDescription: { webhookInactive: {
id: "IBCBi1", id: "GNKG74",
defaultMessage: defaultMessage: "Inactive",
"If you want to disable this webhook please uncheck the box below.", description: "webhooks inactive label",
description: "webhook active description",
}, },
}); });

View file

@ -0,0 +1,87 @@
import "@testing-library/jest-dom";
import {
WebhookEventTypeAsyncEnum,
WebhookEventTypeSyncEnum,
} from "@dashboard/graphql";
import { Fetcher } from "@graphiql/toolkit";
import { render, screen } from "@testing-library/react";
import React from "react";
import WebhookSubscriptionQuery from "./WebhookSubscriptionQuery";
jest.mock("@graphiql/toolkit", () => ({
clear: jest.fn(),
createGraphiQLFetcher: jest.fn(_x => jest.fn() as Fetcher),
}));
jest.mock("react-intl", () => ({
useIntl: jest.fn(() => ({
formatMessage: jest.fn(x => x.defaultMessage),
})),
defineMessages: jest.fn(x => x),
}));
jest.mock("@saleor/macaw-ui", () => ({
useTheme: jest.fn(() => () => ({})),
useStyles: jest.fn(() => () => ({})),
makeStyles: jest.fn(() => () => ({})),
DialogHeader: jest.fn(() => () => <></>),
}));
beforeEach(() => {
window.localStorage.clear();
});
describe("WebhookSubscriptionQuery", () => {
it("is available on the webhook page", async () => {
// Arrange
const props = {
query: "",
setQuery: jest.fn(),
data: {
syncEvents: [] as WebhookEventTypeSyncEnum[],
asyncEvents: [] as WebhookEventTypeAsyncEnum[],
isActive: false,
name: "",
targetUrl: "",
subscriptionQuery: "",
},
};
// const user = userEvent.setup();
// Act
render(<WebhookSubscriptionQuery {...props} />);
// Assert
expect(screen.queryByTestId("graphiql-container")).toBeInTheDocument();
expect(screen.queryByTestId("graphiql-container2")).not.toBeInTheDocument();
});
/*
it("triggers setQuery when user enters text", async () => {
// Arrange
const props = {
query: '',
setQuery: jest.fn(),
data: {
syncEvents: [] as WebhookEventTypeSyncEnum[],
asyncEvents: [] as WebhookEventTypeAsyncEnum[],
isActive: false,
name: '',
targetUrl: '',
subscriptionQuery: ''
}
};
const user = userEvent.setup();
// Act
const { getByTestId } = render(<WebhookSubscriptionQuery {...props} />);
const graphiQLContainer = getByTestId("graphiql-container");
user.type(graphiQLContainer, "{}");
// Assert
expect(props.setQuery).toHaveBeenCalled();
});
*/
});

View file

@ -0,0 +1,69 @@
import "graphiql/graphiql.min.css";
import CardTitle from "@dashboard/components/CardTitle";
import { useExplorerPlugin } from "@graphiql/plugin-explorer";
import { createGraphiQLFetcher } from "@graphiql/toolkit";
import { Card, CardContent } from "@material-ui/core";
import clsx from "clsx";
import React from "react";
import { defineMessages, useIntl } from "react-intl";
import GraphiQL from "../../../components/GraphiQL";
import { WebhookFormData } from "../WebhookDetailsPage";
import { useStyles } from "./styles";
const messages = defineMessages({
title: {
id: "QpSQ5w",
defaultMessage: "Payload Query",
description: "Webhook subscription query card title",
},
});
interface WebhookSubscriptionQueryProps {
query: any;
setQuery: React.Dispatch<any>;
data: WebhookFormData;
}
const fetcher = createGraphiQLFetcher({
url: process.env.API_URI,
});
const WebhookSubscriptionQuery: React.FC<WebhookSubscriptionQueryProps> = ({
query,
setQuery,
data,
}) => {
const intl = useIntl();
const explorerPlugin = useExplorerPlugin({
query,
onEdit: setQuery,
});
const classes = useStyles();
return (
<Card
className={clsx(classes.card, data.syncEvents.length && classes.disabled)}
>
<CardTitle title={intl.formatMessage(messages.title)} />
<CardContent className={classes.cardContent}>
<GraphiQL
data-test-id="graphiql-webhook"
defaultEditorToolsVisibility={"headers"}
fetcher={fetcher}
query={query}
storage={null}
onEditQuery={setQuery}
plugins={[explorerPlugin]}
isHeadersEditorEnabled={false}
/>
</CardContent>
</Card>
);
};
WebhookSubscriptionQuery.displayName = "WebhookSubscriptionQuery";
export default WebhookSubscriptionQuery;

View file

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

View file

@ -0,0 +1,24 @@
import { defineMessages } from "react-intl";
export const messages = defineMessages({
createWebhook: {
id: "wlr0Si",
defaultMessage: "Create Webhook",
description: "button",
},
action: {
id: "a/QJBx",
defaultMessage: "Action",
description: "user action bar",
},
unnamedWebhook: {
id: "Lm2Zw7",
defaultMessage: "Unnamed webhook",
description: "name placeholder",
},
noWebhooks: {
id: "Bkxrhw",
defaultMessage: "No webhooks found",
description: "no webhooks message",
},
});

View file

@ -0,0 +1,18 @@
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles(
theme => ({
disabled: {
pointerEvents: "none",
opacity: 0.6,
},
cardContent: {
height: 500,
padding: 0,
},
card: {
marginBottom: theme.spacing(2),
},
}),
{ name: "WebhookSubscriptionQuery" },
);

View file

@ -13,5 +13,7 @@ export const webhook: WebhookDetailsFragment = {
isActive: true, isActive: true,
name: "Webhook Test 2", name: "Webhook Test 2",
secretKey: "zxczx_asdas", secretKey: "zxczx_asdas",
subscriptionQuery:
"subscription { event { ... on ProductUpdated { product { name } } } }",
targetUrl: "http://www.getsaleor.com", targetUrl: "http://www.getsaleor.com",
}; };

View file

@ -3,35 +3,127 @@ import {
WebhookEventTypeSyncEnum, WebhookEventTypeSyncEnum,
} from "@dashboard/graphql"; } from "@dashboard/graphql";
import { ChangeEvent } from "@dashboard/hooks/useForm"; import { ChangeEvent } from "@dashboard/hooks/useForm";
import { capitalize } from "@dashboard/misc";
import { toggle } from "@dashboard/utils/lists"; import { toggle } from "@dashboard/utils/lists";
import {
InlineFragmentNode,
ObjectFieldNode,
parse,
print,
visit,
} from "graphql";
import { filterSelectedAsyncEvents } from "./utils"; import { filterSelectedAsyncEvents } from "./utils";
export const createSyncEventsSelectHandler = ( export const createSyncEventsSelectHandler =
change: (event: ChangeEvent, cb?: () => void) => void, (
syncEvents: WebhookEventTypeSyncEnum[], change: (event: ChangeEvent, cb?: () => void) => void,
) => (event: ChangeEvent) => { syncEvents: WebhookEventTypeSyncEnum[],
const events = toggle(event.target.value, syncEvents, (a, b) => a === b); setQuery: React.Dispatch<React.SetStateAction<string>>,
) =>
(event: ChangeEvent) => {
const events = toggle(event.target.value, syncEvents, (a, b) => a === b);
change({ // Clear query
target: { setQuery("");
name: "syncEvents",
value: events, // Clear asyncEvents
}, change({
}); target: {
}; name: "asyncEvents",
value: [],
export const createAsyncEventsSelectHandler = ( },
change: (event: ChangeEvent, cb?: () => void) => void, });
asyncEvents: WebhookEventTypeAsyncEnum[],
) => (event: ChangeEvent) => { change({
const events = toggle(event.target.value, asyncEvents, (a, b) => a === b); target: {
const filteredEvents = filterSelectedAsyncEvents(events); name: "syncEvents",
value: events,
change({ },
target: { });
name: "asyncEvents", };
value: filteredEvents,
}, export const createAsyncEventsSelectHandler =
}); (
change: (event: ChangeEvent, cb?: () => void) => void,
asyncEvents: WebhookEventTypeAsyncEnum[],
query: string,
setQuery: React.Dispatch<React.SetStateAction<string>>,
) =>
(event: ChangeEvent) => {
const events = toggle(event.target.value, asyncEvents, (a, b) => a === b);
const filteredEvents = filterSelectedAsyncEvents(events);
// Clear syncEvents
change({
target: {
name: "syncEvents",
value: [],
},
});
change({
target: {
name: "asyncEvents",
value: filteredEvents,
},
});
handleQuery(filteredEvents, query, setQuery);
};
const handleQuery = (
events: WebhookEventTypeAsyncEnum[],
query: string,
setQuery: React.Dispatch<React.SetStateAction<string>>,
) => {
if (events.length > 0 && query.length === 0) {
const event = events[0]
.toLowerCase()
.split("_")
.map(chunk => capitalize(chunk))
.join("");
setQuery(
print(parse(`subscription { event { ... on ${event} { __typename } } }`)),
);
}
if (query.length > 0) {
const ast = parse(query);
visit(ast, {
SelectionSet(node, _key, parent) {
if ((parent as ObjectFieldNode).name?.value === "event") {
const queryEvents = node.selections.map(
selection =>
(selection as InlineFragmentNode).typeCondition.name.value,
);
const newEvents = events
.map(event =>
event
.toLowerCase()
.split("_")
.map(chunk => capitalize(chunk))
.join(""),
)
.filter(event => !queryEvents.includes(event));
if (newEvents.length > 0) {
// TODO modify AST
const inserted = query.replace(/\n/g, " ").replace(
" } } ",
newEvents
.map(event => ` ... on ${event} { __typename }`)
.join("")
.concat(" } } "),
);
setQuery(print(parse(inserted)));
}
}
return undefined;
},
});
}
}; };

View file

@ -14,26 +14,30 @@ export function isUnnamed(webhook: WebhookFragment | undefined): boolean {
type WebhookEventType = WebhookEventTypeSyncEnum | WebhookEventTypeAsyncEnum; type WebhookEventType = WebhookEventTypeSyncEnum | WebhookEventTypeAsyncEnum;
const isWebhookInPreview = (webhook: WebhookEventType) => const isWebhookInPreview = (webhook: WebhookEventType) =>
([ (
WebhookEventTypeSyncEnum.CHECKOUT_CALCULATE_TAXES, [
WebhookEventTypeSyncEnum.ORDER_CALCULATE_TAXES, WebhookEventTypeSyncEnum.CHECKOUT_CALCULATE_TAXES,
] as WebhookEventType[]).includes(webhook); WebhookEventTypeSyncEnum.ORDER_CALCULATE_TAXES,
] as WebhookEventType[]
).includes(webhook);
const isAsyncWebhookInPreview = (webhook: WebhookEventType) => const isAsyncWebhookInPreview = (webhook: WebhookEventType) =>
([ (
WebhookEventTypeAsyncEnum.GIFT_CARD_METADATA_UPDATED, [
WebhookEventTypeAsyncEnum.ORDER_METADATA_UPDATED, WebhookEventTypeAsyncEnum.GIFT_CARD_METADATA_UPDATED,
WebhookEventTypeAsyncEnum.CUSTOMER_METADATA_UPDATED, WebhookEventTypeAsyncEnum.ORDER_METADATA_UPDATED,
WebhookEventTypeAsyncEnum.COLLECTION_METADATA_UPDATED, WebhookEventTypeAsyncEnum.CUSTOMER_METADATA_UPDATED,
WebhookEventTypeAsyncEnum.PRODUCT_METADATA_UPDATED, WebhookEventTypeAsyncEnum.COLLECTION_METADATA_UPDATED,
WebhookEventTypeAsyncEnum.PRODUCT_VARIANT_METADATA_UPDATED, WebhookEventTypeAsyncEnum.PRODUCT_METADATA_UPDATED,
WebhookEventTypeAsyncEnum.CHECKOUT_METADATA_UPDATED, WebhookEventTypeAsyncEnum.PRODUCT_VARIANT_METADATA_UPDATED,
WebhookEventTypeAsyncEnum.FULFILLMENT_METADATA_UPDATED, WebhookEventTypeAsyncEnum.CHECKOUT_METADATA_UPDATED,
WebhookEventTypeAsyncEnum.SHIPPING_ZONE_METADATA_UPDATED, WebhookEventTypeAsyncEnum.FULFILLMENT_METADATA_UPDATED,
WebhookEventTypeAsyncEnum.TRANSACTION_ITEM_METADATA_UPDATED, WebhookEventTypeAsyncEnum.SHIPPING_ZONE_METADATA_UPDATED,
WebhookEventTypeAsyncEnum.WAREHOUSE_METADATA_UPDATED, WebhookEventTypeAsyncEnum.TRANSACTION_ITEM_METADATA_UPDATED,
WebhookEventTypeAsyncEnum.VOUCHER_METADATA_UPDATED, WebhookEventTypeAsyncEnum.WAREHOUSE_METADATA_UPDATED,
] as WebhookEventType[]).includes(webhook); WebhookEventTypeAsyncEnum.VOUCHER_METADATA_UPDATED,
] as WebhookEventType[]
).includes(webhook);
export function mapSyncEventsToChoices( export function mapSyncEventsToChoices(
events: WebhookEventTypeSyncEnum[], events: WebhookEventTypeSyncEnum[],

View file

@ -58,6 +58,7 @@ export const CustomAppWebhookCreate: React.FC<CustomAppWebhookCreateProps> = ({
name: data.name, name: data.name,
secretKey: data.secretKey, secretKey: data.secretKey,
targetUrl: data.targetUrl, targetUrl: data.targetUrl,
query: data.subscriptionQuery,
}, },
}, },
}), }),

View file

@ -62,10 +62,12 @@ export const CustomAppWebhookDetails: React.FC<CustomAppWebhookDetailsProps> = (
name: data.name, name: data.name,
secretKey: data.secretKey, secretKey: data.secretKey,
targetUrl: data.targetUrl, targetUrl: data.targetUrl,
query: data.subscriptionQuery,
}, },
}, },
}), }),
); );
if (!webhook && !loading) { if (!webhook && !loading) {
return <NotFoundPage backHref={CustomAppUrls.resolveAppListUrl()} />; return <NotFoundPage backHref={CustomAppUrls.resolveAppListUrl()} />;
} }

View file

@ -23,5 +23,6 @@ export const webhookDetailsFragment = gql`
} }
secretKey secretKey
targetUrl targetUrl
subscriptionQuery
} }
`; `;

View file

@ -2828,6 +2828,7 @@ export const WebhookDetailsFragmentDoc = gql`
} }
secretKey secretKey
targetUrl targetUrl
subscriptionQuery
} }
${WebhookFragmentDoc}`; ${WebhookFragmentDoc}`;
export const AppCreateDocument = gql` export const AppCreateDocument = gql`

View file

@ -6734,7 +6734,7 @@ export type WebhookCreateMutationVariables = Exact<{
}>; }>;
export type WebhookCreateMutation = { __typename: 'Mutation', webhookCreate: { __typename: 'WebhookCreate', errors: Array<{ __typename: 'WebhookError', code: WebhookErrorCode, field: string | null, message: string | null }>, webhook: { __typename: 'Webhook', secretKey: string | null, targetUrl: string, id: string, name: string, isActive: boolean, syncEvents: Array<{ __typename: 'WebhookEventSync', eventType: WebhookEventTypeSyncEnum }>, asyncEvents: Array<{ __typename: 'WebhookEventAsync', eventType: WebhookEventTypeAsyncEnum }>, app: { __typename: 'App', id: string, name: string | null } } | null } | null }; export type WebhookCreateMutation = { __typename: 'Mutation', webhookCreate: { __typename: 'WebhookCreate', errors: Array<{ __typename: 'WebhookError', code: WebhookErrorCode, field: string | null, message: string | null }>, webhook: { __typename: 'Webhook', secretKey: string | null, targetUrl: string, subscriptionQuery: string | null, id: string, name: string, isActive: boolean, syncEvents: Array<{ __typename: 'WebhookEventSync', eventType: WebhookEventTypeSyncEnum }>, asyncEvents: Array<{ __typename: 'WebhookEventAsync', eventType: WebhookEventTypeAsyncEnum }>, app: { __typename: 'App', id: string, name: string | null } } | null } | null };
export type WebhookUpdateMutationVariables = Exact<{ export type WebhookUpdateMutationVariables = Exact<{
id: Scalars['ID']; id: Scalars['ID'];
@ -6742,7 +6742,7 @@ export type WebhookUpdateMutationVariables = Exact<{
}>; }>;
export type WebhookUpdateMutation = { __typename: 'Mutation', webhookUpdate: { __typename: 'WebhookUpdate', errors: Array<{ __typename: 'WebhookError', code: WebhookErrorCode, field: string | null, message: string | null }>, webhook: { __typename: 'Webhook', secretKey: string | null, targetUrl: string, id: string, name: string, isActive: boolean, syncEvents: Array<{ __typename: 'WebhookEventSync', eventType: WebhookEventTypeSyncEnum }>, asyncEvents: Array<{ __typename: 'WebhookEventAsync', eventType: WebhookEventTypeAsyncEnum }>, app: { __typename: 'App', id: string, name: string | null } } | null } | null }; export type WebhookUpdateMutation = { __typename: 'Mutation', webhookUpdate: { __typename: 'WebhookUpdate', errors: Array<{ __typename: 'WebhookError', code: WebhookErrorCode, field: string | null, message: string | null }>, webhook: { __typename: 'Webhook', secretKey: string | null, targetUrl: string, subscriptionQuery: string | null, id: string, name: string, isActive: boolean, syncEvents: Array<{ __typename: 'WebhookEventSync', eventType: WebhookEventTypeSyncEnum }>, asyncEvents: Array<{ __typename: 'WebhookEventAsync', eventType: WebhookEventTypeAsyncEnum }>, app: { __typename: 'App', id: string, name: string | null } } | null } | null };
export type WebhookDeleteMutationVariables = Exact<{ export type WebhookDeleteMutationVariables = Exact<{
id: Scalars['ID']; id: Scalars['ID'];
@ -6756,7 +6756,7 @@ export type WebhookDetailsQueryVariables = Exact<{
}>; }>;
export type WebhookDetailsQuery = { __typename: 'Query', webhook: { __typename: 'Webhook', secretKey: string | null, targetUrl: string, id: string, name: string, isActive: boolean, syncEvents: Array<{ __typename: 'WebhookEventSync', eventType: WebhookEventTypeSyncEnum }>, asyncEvents: Array<{ __typename: 'WebhookEventAsync', eventType: WebhookEventTypeAsyncEnum }>, app: { __typename: 'App', id: string, name: string | null } } | null }; export type WebhookDetailsQuery = { __typename: 'Query', webhook: { __typename: 'Webhook', secretKey: string | null, targetUrl: string, subscriptionQuery: string | null, id: string, name: string, isActive: boolean, syncEvents: Array<{ __typename: 'WebhookEventSync', eventType: WebhookEventTypeSyncEnum }>, asyncEvents: Array<{ __typename: 'WebhookEventAsync', eventType: WebhookEventTypeAsyncEnum }>, app: { __typename: 'App', id: string, name: string | null } } | null };
export type UpdateCustomerMutationVariables = Exact<{ export type UpdateCustomerMutationVariables = Exact<{
id: Scalars['ID']; id: Scalars['ID'];
@ -7491,7 +7491,7 @@ export type WarehouseDetailsFragment = { __typename: 'Warehouse', isPrivate: boo
export type WebhookFragment = { __typename: 'Webhook', id: string, name: string, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }; export type WebhookFragment = { __typename: 'Webhook', id: string, name: string, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } };
export type WebhookDetailsFragment = { __typename: 'Webhook', secretKey: string | null, targetUrl: string, id: string, name: string, isActive: boolean, syncEvents: Array<{ __typename: 'WebhookEventSync', eventType: WebhookEventTypeSyncEnum }>, asyncEvents: Array<{ __typename: 'WebhookEventAsync', eventType: WebhookEventTypeAsyncEnum }>, app: { __typename: 'App', id: string, name: string | null } }; export type WebhookDetailsFragment = { __typename: 'Webhook', secretKey: string | null, targetUrl: string, subscriptionQuery: string | null, id: string, name: string, isActive: boolean, syncEvents: Array<{ __typename: 'WebhookEventSync', eventType: WebhookEventTypeSyncEnum }>, asyncEvents: Array<{ __typename: 'WebhookEventAsync', eventType: WebhookEventTypeAsyncEnum }>, app: { __typename: 'App', id: string, name: string | null } };
export type WeightFragment = { __typename: 'Weight', unit: WeightUnitsEnum, value: number }; export type WeightFragment = { __typename: 'Weight', unit: WeightUnitsEnum, value: number };

View file

@ -4,6 +4,22 @@ import { configure } from "@testing-library/react";
document.getElementById = () => document.createElement("div"); document.getElementById = () => document.createElement("div");
// workaround for `jsdom`
// https://github.com/jsdom/jsdom/issues/3002
document.createRange = () => {
const range = new Range();
range.getBoundingClientRect = jest.fn();
range.getClientRects = () => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
});
return range;
};
window.__SALEOR_CONFIG__ = { window.__SALEOR_CONFIG__ = {
API_URL: "http://localhost:8000/graphql/", API_URL: "http://localhost:8000/graphql/",
APP_MOUNT_URI: "/", APP_MOUNT_URI: "/",