diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3ba1a80..eb6cdb5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 880f463ee..002b48e53 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -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" diff --git a/src/components/DryRun/DryRun.test.tsx b/src/components/DryRun/DryRun.test.tsx index 10f0c2715..368e54096 100644 --- a/src/components/DryRun/DryRun.test.tsx +++ b/src/components/DryRun/DryRun.test.tsx @@ -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 diff --git a/src/components/DryRun/DryRun.tsx b/src/components/DryRun/DryRun.tsx index 6f0152054..23ee489ea 100644 --- a/src/components/DryRun/DryRun.tsx +++ b/src/components/DryRun/DryRun.tsx @@ -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>; - asyncEvents: WebhookEventTypeAsyncEnum[]; setResult: Dispatch>; + syncEvents: WebhookEventTypeSyncEnum[]; } -export const DryRun: React.FC = ({ +const DryRun: React.FC = ({ setResult, showDialog, setShowDialog, query, + syncEvents, }: DryRunProps) => { const intl = useIntl(); const classes = useStyles(); const [objectId, setObjectId] = useState(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(null); const dryRun = async () => { @@ -71,6 +74,23 @@ export const DryRun: React.FC = ({ return <>; } + if (syncEvents.length > 0) { + return ( + + + {intl.formatMessage(messages.header)} + + + + + {intl.formatMessage(messages.unavailableSyncEvents)} + + + + + ); + } + return ( @@ -84,8 +104,8 @@ export const DryRun: React.FC = ({ {!!unavailableObjects.length && ( - {intl.formatMessage(messages.unavailableObjects)} -   + {intl.formatMessage(messages.unavailableEvents)} +
{unavailableObjects.join(", ")}
@@ -158,7 +178,7 @@ export const DryRun: React.FC = ({ color="primary" variant="primary" onClick={dryRun} - disabled={!availableObjects.length} + disabled={!object} > {intl.formatMessage(messages.run)} diff --git a/src/components/DryRun/messages.ts b/src/components/DryRun/messages.ts index b4cd41c09..f07c43904 100644 --- a/src/components/DryRun/messages.ts +++ b/src/components/DryRun/messages.ts @@ -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", diff --git a/src/components/DryRun/utils.test.ts b/src/components/DryRun/utils.test.ts new file mode 100644 index 000000000..92f25d909 --- /dev/null +++ b/src/components/DryRun/utils.test.ts @@ -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"]); + }); +}); diff --git a/src/components/DryRun/utils.ts b/src/components/DryRun/utils.ts index 993e1fc12..2338f06e5 100644 --- a/src/components/DryRun/utils.ts +++ b/src/components/DryRun/utils.ts @@ -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)); }; diff --git a/src/components/DryRunItemsList/DryRunItemsList.tsx b/src/components/DryRunItemsList/DryRunItemsList.tsx index 471874ab5..5b425a9e0 100644 --- a/src/components/DryRunItemsList/DryRunItemsList.tsx +++ b/src/components/DryRunItemsList/DryRunItemsList.tsx @@ -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; object: string; } -const DryRunItemsList = (props: DryRunItemsListProps) => { +const DryRunItemsList: React.FC = ({ + 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`); diff --git a/src/components/GraphiQL/GraphiQL.tsx b/src/components/GraphiQL/GraphiQL.tsx index 52ff1b3cc..c2e4e1773 100644 --- a/src/components/GraphiQL/GraphiQL.tsx +++ b/src/components/GraphiQL/GraphiQL.tsx @@ -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} /> ); diff --git a/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.tsx b/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.tsx index 2f7c8434f..6402ab265 100644 --- a/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.tsx +++ b/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.tsx @@ -96,17 +96,18 @@ const WebhookDetailsPage: React.FC = ({ return (
{({ 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 ( @@ -127,6 +128,7 @@ const WebhookDetailsPage: React.FC = ({ diff --git a/src/custom-apps/components/WebhookEvents/WebhookEvents.tsx b/src/custom-apps/components/WebhookEvents/WebhookEvents.tsx index ef98ddae5..f442283db 100644 --- a/src/custom-apps/components/WebhookEvents/WebhookEvents.tsx +++ b/src/custom-apps/components/WebhookEvents/WebhookEvents.tsx @@ -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>; onSyncEventChange: (event: ChangeEvent) => void; onAsyncEventChange: (event: ChangeEvent) => void; } const WebhookEvents: React.FC = ({ data, + setQuery, onSyncEventChange, onAsyncEventChange, }) => { @@ -56,14 +59,19 @@ const WebhookEvents: React.FC = ({ 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 = ({ }; WebhookEvents.displayName = "WebhookEvents"; export default WebhookEvents; - -type Actions = string[]; - -export const AsyncWebhookTypes: Record = { - 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 = { - 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; diff --git a/src/custom-apps/components/WebhookEvents/utils.test.ts b/src/custom-apps/components/WebhookEvents/utils.test.ts new file mode 100644 index 000000000..cc87f8a0f --- /dev/null +++ b/src/custom-apps/components/WebhookEvents/utils.test.ts @@ -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", + ]); + }); +}); diff --git a/src/custom-apps/components/WebhookEvents/utils.ts b/src/custom-apps/components/WebhookEvents/utils.ts new file mode 100644 index 000000000..cc9a8cbee --- /dev/null +++ b/src/custom-apps/components/WebhookEvents/utils.ts @@ -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>((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 = getWebhookTypes( + Object.keys(WebhookEventTypeAsyncEnum), +); + +const SyncWebhookTypes: Record = 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; +}; diff --git a/src/custom-apps/components/WebhookSubscriptionQuery/WebhookSubscriptionQuery.tsx b/src/custom-apps/components/WebhookSubscriptionQuery/WebhookSubscriptionQuery.tsx index 266f7d43d..961423f95 100644 --- a/src/custom-apps/components/WebhookSubscriptionQuery/WebhookSubscriptionQuery.tsx +++ b/src/custom-apps/components/WebhookSubscriptionQuery/WebhookSubscriptionQuery.tsx @@ -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 = ({ const classes = useStyles(); return ( - - + + = ({ onEditQuery={setQuery} plugins={[explorerPlugin]} isHeadersEditorEnabled={false} - asyncEvents={data.asyncEvents} + data={data} /> diff --git a/src/custom-apps/handlers.ts b/src/custom-apps/handlers.ts index 102a6aa63..9d27d5b4e 100644 --- a/src/custom-apps/handlers.ts +++ b/src/custom-apps/handlers.ts @@ -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>; +} + export const createSyncEventsSelectHandler = - ( - change: (event: ChangeEvent, cb?: () => void) => void, - syncEvents: WebhookEventTypeSyncEnum[], - setQuery: React.Dispatch>, - ) => + ({ change, data, query, setQuery }: CreateSyncEventsSelectHandler) => (event: ChangeEvent) => { + const { syncEvents, asyncEvents } = data; const events = toggle(event.target.value, syncEvents, (a, b) => a === b); - // Clear query - setQuery(""); - // Clear asyncEvents - change({ - target: { - name: "asyncEvents", - value: [], - }, - }); + if (!isEmpty(asyncEvents)) { + setQuery(""); + + 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>; +} + export const createAsyncEventsSelectHandler = - ( - change: (event: ChangeEvent, cb?: () => void) => void, - asyncEvents: WebhookEventTypeAsyncEnum[], - query: string, - setQuery: React.Dispatch>, - ) => + ({ 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 - change({ - target: { - name: "syncEvents", - value: [], - }, - }); + 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>, ) => {