Allow subscription query for sync events (#3099)

Allow subscription query for sync events
This commit is contained in:
Bartłomiej Wiaduch 2023-02-23 10:03:41 +01:00 committed by GitHub
parent 9f54a7840c
commit 0fef41b04f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 250 additions and 157 deletions

View file

@ -21,6 +21,7 @@ All notable, unreleased changes to this project will be documented in this file.
- Add redirect to GraphiQL from product & order details pages - #2940 by @zaiste
- Extract permissions for subscription query - #3155 by @zaiste
- Add custom request headers to webhook form - #3107 by @2can
- Allow subscription query for sync events - #3099 by @2can
## 3.4

View file

@ -1618,10 +1618,6 @@
"context": "sale status",
"string": "Active"
},
"ApNw0L": {
"context": "Dry run objects unavailable",
"string": "The following objects are currently not available for dry run:"
},
"AqHafs": {
"context": "provided email input placeholder",
"string": "Provided email address"
@ -1731,6 +1727,10 @@
"context": "button label",
"string": "Settings"
},
"BYTvv/": {
"context": "Dry run events unavailable",
"string": "The following events from provided query are currently not available for dry run:"
},
"BZ7BkQ": {
"context": "capture payment, button",
"string": "Capture"
@ -4187,6 +4187,10 @@
"context": "pricing card title",
"string": "Pricing"
},
"TnnDjx": {
"context": "Dry run sync events alert",
"string": "Dry run currently is not available for synchronous events"
},
"TnyLrZ": {
"context": "PageTypeDeleteWarningDialog with items multiple description",
"string": "You are about to delete multiple page types. Some of them are assigned to pages. Deleting those page types will also delete those pages"

View file

@ -1,5 +1,5 @@
import { MockedProvider, MockedResponse } from "@apollo/client/testing";
import { WebhookEventTypeAsyncEnum } from "@dashboard/graphql";
import { WebhookEventTypeSyncEnum } from "@dashboard/graphql";
import { ThemeProvider } from "@saleor/macaw-ui";
import productsMocks from "@test/mocks/products";
import { render, screen } from "@testing-library/react";
@ -23,8 +23,8 @@ describe("DryRun", () => {
query: "",
showDialog: true,
setShowDialog: jest.fn(),
asyncEvents: [] as WebhookEventTypeAsyncEnum[],
setResult: jest.fn(),
syncEvents: [] as WebhookEventTypeSyncEnum[],
};
// Act

View file

@ -2,7 +2,7 @@ import Grid from "@dashboard/components/Grid";
import { useStyles } from "@dashboard/custom-apps/components/WebhookEvents/styles";
import {
useTriggerWebhookDryRunMutation,
WebhookEventTypeAsyncEnum,
WebhookEventTypeSyncEnum,
} from "@dashboard/graphql";
import {
capitalize,
@ -26,30 +26,33 @@ import React, { Dispatch, SetStateAction, useState } from "react";
import { useIntl } from "react-intl";
import DryRunItemsList from "../DryRunItemsList/DryRunItemsList";
import { DocumentMap } from "../DryRunItemsList/utils";
import { messages } from "./messages";
import { getObjects } from "./utils";
import { getUnavailableObjects } from "./utils";
interface DryRunProps {
query: string;
showDialog: boolean;
setShowDialog: Dispatch<SetStateAction<boolean>>;
asyncEvents: WebhookEventTypeAsyncEnum[];
setResult: Dispatch<SetStateAction<string>>;
syncEvents: WebhookEventTypeSyncEnum[];
}
export const DryRun: React.FC<DryRunProps> = ({
const DryRun: React.FC<DryRunProps> = ({
setResult,
showDialog,
setShowDialog,
query,
syncEvents,
}: DryRunProps) => {
const intl = useIntl();
const classes = useStyles();
const [objectId, setObjectId] = useState<string | null>(null);
const [triggerWebhookDryRun] = useTriggerWebhookDryRunMutation();
const availableObjects = getObjects(query);
const unavailableObjects = getObjects(query, false);
const availableObjects = Object.keys(DocumentMap).map(object =>
capitalize(object.split("_").join(" ").toLowerCase()),
);
const unavailableObjects = getUnavailableObjects(query);
const [object, setObject] = useState<string | null>(null);
const dryRun = async () => {
@ -71,6 +74,23 @@ export const DryRun: React.FC<DryRunProps> = ({
return <></>;
}
if (syncEvents.length > 0) {
return (
<Dialog open={showDialog} fullWidth maxWidth="md" data-test-id="dry-run">
<DialogHeader onClose={closeDialog}>
{intl.formatMessage(messages.header)}
</DialogHeader>
<DialogContent style={{ overflow: "scroll" }}>
<Alert variant="error" close={false}>
<Typography>
{intl.formatMessage(messages.unavailableSyncEvents)}
</Typography>
</Alert>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={showDialog} fullWidth maxWidth="md" data-test-id="dry-run">
<DialogHeader onClose={closeDialog}>
@ -84,8 +104,8 @@ export const DryRun: React.FC<DryRunProps> = ({
{!!unavailableObjects.length && (
<Alert variant="warning" close={false}>
<Typography>
{intl.formatMessage(messages.unavailableObjects)}
&nbsp;
{intl.formatMessage(messages.unavailableEvents)}
<br />
<strong>{unavailableObjects.join(", ")}</strong>
</Typography>
</Alert>
@ -158,7 +178,7 @@ export const DryRun: React.FC<DryRunProps> = ({
color="primary"
variant="primary"
onClick={dryRun}
disabled={!availableObjects.length}
disabled={!object}
>
{intl.formatMessage(messages.run)}
</Button>

View file

@ -6,16 +6,21 @@ export const messages = defineMessages({
defaultMessage: "Dry run",
description: "Dry run dialog header",
},
unavailableSyncEvents: {
id: "TnnDjx",
defaultMessage: "Dry run currently is not available for synchronous events",
description: "Dry run sync events alert",
},
selectObject: {
id: "+RffqY",
defaultMessage: "Select object type to perform dry run on provided query",
description: "Dry run dialog object title",
},
unavailableObjects: {
id: "ApNw0L",
unavailableEvents: {
id: "BYTvv/",
defaultMessage:
"The following objects are currently not available for dry run:",
description: "Dry run objects unavailable",
"The following events from provided query are currently not available for dry run:",
description: "Dry run events unavailable",
},
objects: {
id: "uccjUM",

View file

@ -0,0 +1,24 @@
import { getUnavailableObjects } from "./utils";
describe("getUnavailableObjects", () => {
it("should return unavailable for dry run events from provided query", () => {
const query = `
subscription {
event {
... on ProductUpdated {
__typename
}
... on ProductDeleted {
__typename
}
... on AddressUpdated {
__typename
}
}
}`;
const events = getUnavailableObjects(query);
expect(events).toEqual(["AddressUpdated"]);
});
});

View file

@ -1,8 +1,8 @@
import { AsyncWebhookTypes } from "@dashboard/custom-apps/components/WebhookEvents";
import { getWebhookTypes } from "@dashboard/custom-apps/components/WebhookEvents/utils";
import { WebhookEventTypeAsyncEnum } from "@dashboard/graphql";
import { InlineFragmentNode, ObjectFieldNode, parse, visit } from "graphql";
import uniq from "lodash/uniq";
import { ExcludedDocumentMap } from "../DryRunItemsList/utils";
import { DocumentMap, ExcludedDocumentMap } from "../DryRunItemsList/utils";
const getEventsFromQuery = (query: string) => {
if (query.length === 0) {
@ -32,28 +32,32 @@ const getEventsFromQuery = (query: string) => {
}
};
export const getObjects = (query: string, available = true) => {
export const getUnavailableObjects = (query: string) => {
const queryEvents = getEventsFromQuery(query);
return uniq(
queryEvents.map(event => {
const object = event.split(/(?=[A-Z])/).slice(0, -1);
if (
Object.keys(AsyncWebhookTypes)
.filter(object =>
available
? !Object.keys(ExcludedDocumentMap).includes(object.toUpperCase())
: Object.keys(ExcludedDocumentMap).includes(object.toUpperCase()),
)
.includes(object.join("_").toUpperCase())
) {
return object.join(" ");
return queryEvents.reduce((acc, event) => {
const formattedEvent = event
.split(/(?=[A-Z])/)
.join("_")
.toUpperCase();
if (checkEventPresence(formattedEvent)) {
acc.push(event);
}
return event
.split(/(?=[A-Z])/)
.slice(0, -2)
.join(" ");
}),
).filter(object => object.length > 0);
return acc;
}, []);
};
const checkEventPresence = (event: string) => {
const webhookTypes = getWebhookTypes(Object.keys(WebhookEventTypeAsyncEnum));
const availableObjects = Object.keys(DocumentMap);
const excludedObjects = Object.keys(webhookTypes).filter(
object => !availableObjects.includes(object),
);
Object.keys(ExcludedDocumentMap).forEach(
object => !excludedObjects.includes(object) && excludedObjects.push(object),
);
return excludedObjects.some(object => event.startsWith(object));
};

View file

@ -18,17 +18,21 @@ import { useIntl } from "react-intl";
import Avatar from "../TableCellAvatar/Avatar";
import { messages } from "./messages";
import { DocumentMap, TData, TVariables } from "./utils";
interface DryRunItemsListProps {
export interface DryRunItemsListProps {
objectId: string;
setObjectId: React.Dispatch<any>;
object: string;
}
const DryRunItemsList = (props: DryRunItemsListProps) => {
const DryRunItemsList: React.FC<DryRunItemsListProps> = ({
object,
objectId,
setObjectId,
}: DryRunItemsListProps) => {
const intl = useIntl();
const classes = useStyles();
const { checkbox } = useListWidths();
const { object, objectId, setObjectId } = props;
const objectDocument = DocumentMap[object];
const objectCollection =
objectDocument.collection ?? camelCase(`${object.toLowerCase()}s`);

View file

@ -1,4 +1,4 @@
import { WebhookEventTypeAsyncEnum } from "@dashboard/graphql";
import { WebhookFormData } from "@dashboard/custom-apps/components/WebhookDetailsPage";
import {
CopyIcon,
GraphiQLProvider,
@ -78,7 +78,7 @@ export function GraphiQL({
visiblePlugin,
defaultHeaders,
...props
}: GraphiQLProps & { asyncEvents: WebhookEventTypeAsyncEnum[] }) {
}: GraphiQLProps & { data: WebhookFormData }) {
// Ensure props are correct
if (typeof fetcher !== "function") {
throw new TypeError(
@ -130,7 +130,7 @@ export function GraphiQL({
setShowDialog={setShowDialog}
query={query}
setResult={setResult}
asyncEvents={props.asyncEvents}
syncEvents={props.data.syncEvents}
/>
</GraphiQLProvider>
);

View file

@ -96,17 +96,18 @@ const WebhookDetailsPage: React.FC<WebhookDetailsPageProps> = ({
return (
<Form confirmLeave initial={initialForm} onSubmit={handleSubmit}>
{({ data, submit, change }) => {
const handleSyncEventsSelect = createSyncEventsSelectHandler(
const handleSyncEventsSelect = createSyncEventsSelectHandler({
change,
data.syncEvents,
setQuery,
);
const handleAsyncEventsSelect = createAsyncEventsSelectHandler(
change,
data.asyncEvents,
data,
query,
setQuery,
);
});
const handleAsyncEventsSelect = createAsyncEventsSelectHandler({
change,
data,
query,
setQuery,
});
return (
<DetailedContent useSingleColumn>
@ -127,6 +128,7 @@ const WebhookDetailsPage: React.FC<WebhookDetailsPageProps> = ({
<FormSpacer />
<WebhookEvents
data={data}
setQuery={setQuery}
onSyncEventChange={handleSyncEventsSelect}
onAsyncEventChange={handleAsyncEventsSelect}
/>

View file

@ -19,23 +19,26 @@ import {
Pill,
useListWidths,
} from "@saleor/macaw-ui";
import React, { useState } from "react";
import React, { Dispatch, SetStateAction, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { messages } from "./messages";
import { useStyles } from "./styles";
import { EventTypes, getEventName } from "./utils";
interface WebhookEventsProps {
data: {
syncEvents: WebhookEventTypeSyncEnum[];
asyncEvents: WebhookEventTypeAsyncEnum[];
};
setQuery: Dispatch<SetStateAction<string>>;
onSyncEventChange: (event: ChangeEvent) => void;
onAsyncEventChange: (event: ChangeEvent) => void;
}
const WebhookEvents: React.FC<WebhookEventsProps> = ({
data,
setQuery,
onSyncEventChange,
onAsyncEventChange,
}) => {
@ -56,14 +59,19 @@ const WebhookEvents: React.FC<WebhookEventsProps> = ({
const handleTabChange = value => {
setObject(null);
setQuery("");
setTab(value);
};
const countEvents = object => {
const selected = tab === "sync" ? data.syncEvents : data.asyncEvents;
const objectEvents = EventTypes[tab][object].map(
event => `${object}_${event}`,
);
const objectEvents = EventTypes[tab][object].map(event => {
if (event === object) {
return object;
}
return `${object}_${event}`;
});
return objectEvents.filter((event: never) => selected.includes(event))
.length;
@ -177,62 +185,3 @@ const WebhookEvents: React.FC<WebhookEventsProps> = ({
};
WebhookEvents.displayName = "WebhookEvents";
export default WebhookEvents;
type Actions = string[];
export 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

@ -0,0 +1,23 @@
import { getWebhookTypes } from "./utils";
const TestKeys = [
"DRAFT_ORDER_CREATED",
"DRAFT_ORDER_UPDATED",
"PRODUCT_CREATED",
"PRODUCT_UPDATED",
"PRODUCT_VARIANT_UPDATED",
];
describe("getWebhookTypes", () => {
it("should map array of enum keys to objects with events ", () => {
const TestWebhookTypes = getWebhookTypes(TestKeys);
expect(Object.keys(TestWebhookTypes)).toEqual(["DRAFT_ORDER", "PRODUCT"]);
expect(TestWebhookTypes.DRAFT_ORDER).toEqual(["CREATED", "UPDATED"]);
expect(TestWebhookTypes.PRODUCT).toEqual([
"CREATED",
"UPDATED",
"VARIANT_UPDATED",
]);
});
});

View file

@ -0,0 +1,46 @@
import {
WebhookEventTypeAsyncEnum,
WebhookEventTypeSyncEnum,
} from "@dashboard/graphql";
type Actions = string[];
export const getWebhookTypes = (webhookEvents: string[]) => {
const multiWords = ["DRAFT_ORDER", "GIFT_CARD", "ANY_EVENTS"];
return webhookEvents.reduce<Record<string, Actions>>((acc, key) => {
const keywords = key.split("_");
const multiKeyword = keywords.slice(0, 2).join("_");
const [keyword, sliceSize] = multiWords.includes(multiKeyword)
? [multiKeyword, 2]
: [keywords[0], 1];
const event = keywords.slice(sliceSize).join("_");
const events = acc[keyword] || [];
events.push(!!event.length ? event : multiKeyword);
acc[keyword] = events;
return acc;
}, {});
};
export const AsyncWebhookTypes: Record<string, Actions> = getWebhookTypes(
Object.keys(WebhookEventTypeAsyncEnum),
);
const SyncWebhookTypes: Record<string, Actions> = getWebhookTypes(
Object.keys(WebhookEventTypeSyncEnum),
);
export const EventTypes = {
async: AsyncWebhookTypes,
sync: SyncWebhookTypes,
};
export const getEventName = (object: string, event: string) => {
if (object === event) {
return object.toUpperCase() as WebhookEventTypeSyncEnum;
}
return [object, event].join("_").toUpperCase() as WebhookEventTypeSyncEnum;
};

View file

@ -4,7 +4,6 @@ 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";
@ -45,13 +44,8 @@ const WebhookSubscriptionQuery: React.FC<WebhookSubscriptionQueryProps> = ({
const classes = useStyles();
return (
<Card
className={clsx(classes.card, data.syncEvents.length && classes.disabled)}
>
<CardTitle
title={intl.formatMessage(messages.title)}
className={classes.cardTitle}
/>
<Card className={classes.card}>
<CardTitle title={intl.formatMessage(messages.title)} />
<CardContent className={classes.cardContent}>
<GraphiQL
data-test-id="graphiql-webhook"
@ -62,7 +56,7 @@ const WebhookSubscriptionQuery: React.FC<WebhookSubscriptionQueryProps> = ({
onEditQuery={setQuery}
plugins={[explorerPlugin]}
isHeadersEditorEnabled={false}
asyncEvents={data.asyncEvents}
data={data}
/>
</CardContent>
</Card>

View file

@ -12,28 +12,36 @@ import {
print,
visit,
} from "graphql";
import isEmpty from "lodash/isEmpty";
import React, { Dispatch, SetStateAction } from "react";
import { WebhookFormData } from "./components/WebhookDetailsPage";
import { filterSelectedAsyncEvents } from "./utils";
interface CreateSyncEventsSelectHandler {
change: (event: ChangeEvent, cb?: () => void) => void;
data: WebhookFormData;
query: string;
setQuery: Dispatch<SetStateAction<string>>;
}
export const createSyncEventsSelectHandler =
(
change: (event: ChangeEvent, cb?: () => void) => void,
syncEvents: WebhookEventTypeSyncEnum[],
setQuery: React.Dispatch<React.SetStateAction<string>>,
) =>
({ change, data, query, setQuery }: CreateSyncEventsSelectHandler) =>
(event: ChangeEvent) => {
const { syncEvents, asyncEvents } = data;
const events = toggle(event.target.value, syncEvents, (a, b) => a === b);
// Clear query
// Clear asyncEvents
if (!isEmpty(asyncEvents)) {
setQuery("");
// Clear asyncEvents
change({
target: {
name: "asyncEvents",
value: [],
},
});
}
change({
target: {
@ -41,26 +49,35 @@ export const createSyncEventsSelectHandler =
value: events,
},
});
handleQuery(events, query, setQuery);
};
interface CreateAsyncEventsSelectHandler {
change: (event: ChangeEvent, cb?: () => void) => void;
data: WebhookFormData;
query: string;
setQuery: Dispatch<SetStateAction<string>>;
}
export const createAsyncEventsSelectHandler =
(
change: (event: ChangeEvent, cb?: () => void) => void,
asyncEvents: WebhookEventTypeAsyncEnum[],
query: string,
setQuery: React.Dispatch<React.SetStateAction<string>>,
) =>
({ change, data, query, setQuery }: CreateAsyncEventsSelectHandler) =>
(event: ChangeEvent) => {
const { syncEvents, asyncEvents } = data;
const events = toggle(event.target.value, asyncEvents, (a, b) => a === b);
const filteredEvents = filterSelectedAsyncEvents(events);
// Clear syncEvents
if (!isEmpty(syncEvents)) {
setQuery("");
change({
target: {
name: "syncEvents",
value: [],
},
});
}
change({
target: {
@ -73,7 +90,7 @@ export const createAsyncEventsSelectHandler =
};
const handleQuery = (
events: WebhookEventTypeAsyncEnum[],
events: WebhookEventTypeAsyncEnum[] | WebhookEventTypeSyncEnum[],
query: string,
setQuery: React.Dispatch<React.SetStateAction<string>>,
) => {