diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a78fd1e0..69819c531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,11 @@ All notable, unreleased changes to this project will be documented in this file. - Add custom request headers to webhook form - #3107 by @2can - Allow subscription query for sync events - #3099 by @2can - Simplify codegen configuration to generate the graphql schema - #2538 by @abumalick -- Add DevMode panel (trigger: CMD+') - #3333 by @zaiste -- Migrate to `es2020` for TypeScript - #3386 by @zaiste +- Add DevMode panel (trigger: CMD+') - #3333 by @zaiste +- Migrate to `es2020` for TypeScript - #3386 by @zaiste - Fix styling for GraphiQL on the webhook page - #3389 by @zaiste - Copy filters automatically to GraphiQL playground - #3385 by @zaiste +- Update subscription query with Event types only and improve Event picking - #3357 by @2can ## 3.4 diff --git a/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.stories.tsx b/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.stories.tsx index 934112d53..36bbc32dd 100644 --- a/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.stories.tsx +++ b/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.stories.tsx @@ -16,6 +16,7 @@ const props: WebhookDetailsPageProps = { onSubmit: () => undefined, saveButtonBarState: "default", webhook, + availableEvents: [], }; storiesOf("Apps / Webhooks / Webhook details", module) .addDecorator(Decorator) diff --git a/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.tsx b/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.tsx index 44292f743..5e5825fd1 100644 --- a/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.tsx +++ b/src/custom-apps/components/WebhookDetailsPage/WebhookDetailsPage.tsx @@ -11,6 +11,7 @@ import { createSyncEventsSelectHandler, } from "@dashboard/custom-apps/handlers"; import { CustomAppUrls } from "@dashboard/custom-apps/urls"; +import { IntrospectionNode } from "@dashboard/custom-apps/utils"; import { WebhookDetailsFragment, WebhookErrorFragment, @@ -49,6 +50,7 @@ export interface WebhookDetailsPageProps { webhook?: WebhookDetailsFragment | null; saveButtonBarState: ConfirmButtonTransitionState; onSubmit: (data: WebhookFormData) => SubmitPromise; + availableEvents: IntrospectionNode[]; } const WebhookDetailsPage: React.FC = ({ @@ -58,6 +60,7 @@ const WebhookDetailsPage: React.FC = ({ webhook, saveButtonBarState, onSubmit, + availableEvents, }) => { const intl = useIntl(); const navigate = useNavigator(); @@ -100,12 +103,14 @@ const WebhookDetailsPage: React.FC = ({ data, query, setQuery, + availableEvents, }); const handleAsyncEventsSelect = createAsyncEventsSelectHandler({ change, data, query, setQuery, + availableEvents, }); return ( diff --git a/src/custom-apps/handlers.ts b/src/custom-apps/handlers.ts index 9d27d5b4e..2ddf94c8c 100644 --- a/src/custom-apps/handlers.ts +++ b/src/custom-apps/handlers.ts @@ -6,27 +6,38 @@ import { ChangeEvent } from "@dashboard/hooks/useForm"; import { capitalize } from "@dashboard/misc"; import { toggle } from "@dashboard/utils/lists"; import { + DocumentNode, + FieldNode, InlineFragmentNode, ObjectFieldNode, + OperationDefinitionNode, parse, print, + SelectionNode, visit, } from "graphql"; import isEmpty from "lodash/isEmpty"; import React, { Dispatch, SetStateAction } from "react"; import { WebhookFormData } from "./components/WebhookDetailsPage"; -import { filterSelectedAsyncEvents } from "./utils"; +import { filterSelectedAsyncEvents, IntrospectionNode } from "./utils"; interface CreateSyncEventsSelectHandler { change: (event: ChangeEvent, cb?: () => void) => void; data: WebhookFormData; query: string; setQuery: Dispatch>; + availableEvents: IntrospectionNode[]; } export const createSyncEventsSelectHandler = - ({ change, data, query, setQuery }: CreateSyncEventsSelectHandler) => + ({ + change, + data, + query, + setQuery, + availableEvents, + }: CreateSyncEventsSelectHandler) => (event: ChangeEvent) => { const { syncEvents, asyncEvents } = data; const events = toggle(event.target.value, syncEvents, (a, b) => a === b); @@ -50,7 +61,7 @@ export const createSyncEventsSelectHandler = }, }); - handleQuery(events, query, setQuery); + handleQuery({ events, query, setQuery, availableEvents }); }; interface CreateAsyncEventsSelectHandler { @@ -58,10 +69,17 @@ interface CreateAsyncEventsSelectHandler { data: WebhookFormData; query: string; setQuery: Dispatch>; + availableEvents: IntrospectionNode[]; } export const createAsyncEventsSelectHandler = - ({ change, data, query, setQuery }: CreateAsyncEventsSelectHandler) => + ({ + change, + data, + query, + setQuery, + availableEvents, + }: CreateAsyncEventsSelectHandler) => (event: ChangeEvent) => { const { syncEvents, asyncEvents } = data; const events = toggle(event.target.value, asyncEvents, (a, b) => a === b); @@ -86,62 +104,125 @@ export const createAsyncEventsSelectHandler = }, }); - handleQuery(filteredEvents, query, setQuery); + handleQuery({ events: filteredEvents, query, setQuery, availableEvents }); }; -const handleQuery = ( - events: WebhookEventTypeAsyncEnum[] | WebhookEventTypeSyncEnum[], - query: string, - setQuery: React.Dispatch>, -) => { - if (events.length > 0 && query.length === 0) { - const event = events[0] - .toLowerCase() - .split("_") - .map(chunk => capitalize(chunk)) - .join(""); +const enumToEventName = (value: string) => + value + .toLowerCase() + .split("_") + .map(chunk => capitalize(chunk)) + .join(""); +interface HandleQuery { + events: WebhookEventTypeAsyncEnum[] | WebhookEventTypeSyncEnum[]; + query: string; + setQuery: React.Dispatch>; + availableEvents: IntrospectionNode[]; +} + +const handleQuery = ({ + events, + query, + setQuery, + availableEvents, +}: HandleQuery) => { + const availableEventNames = availableEvents.map(({ name }) => name); + const eventsNames: string[] = events + .map(enumToEventName) + .filter(eventName => availableEventNames.includes(eventName)); + + if (eventsNames.length > 0 && query.length === 0) { setQuery( - print(parse(`subscription { event { ... on ${event} { __typename } } }`)), + print( + parse( + `subscription { event { ... on ${eventsNames[0]} { __typename } } }`, + ), + ), ); } if (query.length > 0) { - const ast = parse(query); + try { + 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(" } } "), + const editedAst: DocumentNode = 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, ); - setQuery(print(parse(inserted))); - } - } - return undefined; - }, - }); + const eventsToRemove = queryEvents.filter( + event => !eventsNames.includes(event), + ); + + const selections = [...node.selections].filter( + selection => + !eventsToRemove.includes( + (selection as InlineFragmentNode).typeCondition.name.value, + ), + ); + + eventsNames.forEach(event => { + if (!queryEvents.includes(event)) { + const eventFragment = createEventInlineFragment(event); + selections.push(eventFragment); + } + }); + + if (!selections.length) { + return null; + } + + return { + ...node, + selections, + }; + } + }, + }); + + setQuery(isEmptyQuery(editedAst) ? "" : print(editedAst)); + } catch (e) { + console.error(e); + } } }; + +const createEventInlineFragment = (event: string): SelectionNode => ({ + kind: "InlineFragment", + typeCondition: { + kind: "NamedType", + name: { + kind: "Name", + value: event, + }, + }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "__typename" } }, + ], + }, +}); + +const isEmptyQuery = (ast: DocumentNode): boolean => { + let empty = false; + + visit(ast, { + SelectionSet(node, _key, parent) { + if ((parent as OperationDefinitionNode).operation === "subscription") { + const event = node.selections.filter( + selection => (selection as FieldNode).name?.value === "event", + )[0] as FieldNode; + if (!event.selectionSet) { + empty = true; + } + } + }, + }); + + return empty; +}; diff --git a/src/custom-apps/hooks.ts b/src/custom-apps/hooks.ts new file mode 100644 index 000000000..af0f894d5 --- /dev/null +++ b/src/custom-apps/hooks.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@apollo/client"; + +import { buildEventsMap, IntrospectionQuery } from "./utils"; + +export const useAvailableEvents = () => { + const { data: introspectionData } = useQuery(IntrospectionQuery, { + fetchPolicy: "network-only", + }); + + const elements = introspectionData?.__schema?.types || []; + const availableEvents = buildEventsMap(elements); + + return availableEvents; +}; diff --git a/src/custom-apps/utils.tsx b/src/custom-apps/utils.tsx index cd5946786..825b4376f 100644 --- a/src/custom-apps/utils.tsx +++ b/src/custom-apps/utils.tsx @@ -1,3 +1,4 @@ +import { gql } from "@apollo/client"; import { WebhookEventTypeAsyncEnum, WebhookFragment } from "@dashboard/graphql"; export function isUnnamed(webhook: WebhookFragment | undefined): boolean { @@ -15,3 +16,31 @@ export const filterSelectedAsyncEvents = ( } return asyncEvents; }; + +export interface IntrospectionNode { + name: string; + interfaces: Array<{ + name: string; + }> | null; + description: string; +} + +// cannot be in `queries.ts` as codegen cannot handle `__schema` +export const IntrospectionQuery = gql` + query EventsIntrospection { + __schema { + types { + name + interfaces { + name + } + description + } + } + } +`; + +const isEvent = ({ name }: { name: string }) => name === "Event"; + +export const buildEventsMap = (elements: IntrospectionNode[]) => + elements.filter(({ interfaces }) => (interfaces || []).some(isEvent)); diff --git a/src/custom-apps/views/CustomAppWebhookCreate.tsx b/src/custom-apps/views/CustomAppWebhookCreate.tsx index f68169910..879bc4412 100644 --- a/src/custom-apps/views/CustomAppWebhookCreate.tsx +++ b/src/custom-apps/views/CustomAppWebhookCreate.tsx @@ -14,6 +14,7 @@ import { useIntl } from "react-intl"; import WebhookDetailsPage, { WebhookFormData, } from "../components/WebhookDetailsPage"; +import { useAvailableEvents } from "../hooks"; import { CustomAppUrls } from "../urls"; export interface CustomAppWebhookCreateProps { @@ -29,6 +30,8 @@ export const CustomAppWebhookCreate: React.FC = ({ const { data } = useAppQuery({ variables: { id: appId } }); + const availableEvents = useAvailableEvents(); + const [webhookCreate, webhookCreateOpts] = useWebhookCreateMutation({ onCompleted: data => { const webhook = data.webhookCreate?.webhook; @@ -80,6 +83,7 @@ export const CustomAppWebhookCreate: React.FC = ({ errors={webhookCreateOpts.data?.webhookCreate?.errors ?? []} onSubmit={handleSubmit} saveButtonBarState={webhookCreateOpts.status} + availableEvents={availableEvents} /> ); diff --git a/src/custom-apps/views/CustomAppWebhookDetails.tsx b/src/custom-apps/views/CustomAppWebhookDetails.tsx index 732f2ae98..a84eb48a0 100644 --- a/src/custom-apps/views/CustomAppWebhookDetails.tsx +++ b/src/custom-apps/views/CustomAppWebhookDetails.tsx @@ -14,6 +14,7 @@ import { extractMutationErrors, getStringOrPlaceholder } from "../../misc"; import WebhookDetailsPage, { WebhookFormData, } from "../components/WebhookDetailsPage"; +import { useAvailableEvents } from "../hooks"; import { CustomAppUrls } from "../urls"; export interface CustomAppWebhookDetailsProps { @@ -26,6 +27,8 @@ export const CustomAppWebhookDetails: React.FC< const notify = useNotifier(); const intl = useIntl(); + const availableEvents = useAvailableEvents(); + const { data: webhookDetails, loading } = useWebhookDetailsQuery({ variables: { id }, }); @@ -86,6 +89,7 @@ export const CustomAppWebhookDetails: React.FC< saveButtonBarState={webhookUpdateOpts.status} webhook={webhook} onSubmit={handleSubmit} + availableEvents={availableEvents} /> );