Update subscription query with Event types only and improve Event picking (#3357)

This commit is contained in:
Bartłomiej Wiaduch 2023-03-23 16:31:04 +01:00 committed by GitHub
parent b36b4859f1
commit 5dabfc315f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 191 additions and 52 deletions

View file

@ -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 - Add custom request headers to webhook form - #3107 by @2can
- Allow subscription query for sync events - #3099 by @2can - Allow subscription query for sync events - #3099 by @2can
- Simplify codegen configuration to generate the graphql schema - #2538 by @abumalick - Simplify codegen configuration to generate the graphql schema - #2538 by @abumalick
- Add DevMode panel (trigger: CMD+') - #3333 by @zaiste - Add DevMode panel (trigger: CMD+') - #3333 by @zaiste
- Migrate to `es2020` for TypeScript - #3386 by @zaiste - Migrate to `es2020` for TypeScript - #3386 by @zaiste
- Fix styling for GraphiQL on the webhook page - #3389 by @zaiste - Fix styling for GraphiQL on the webhook page - #3389 by @zaiste
- Copy filters automatically to GraphiQL playground - #3385 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 ## 3.4

View file

@ -16,6 +16,7 @@ const props: WebhookDetailsPageProps = {
onSubmit: () => undefined, onSubmit: () => undefined,
saveButtonBarState: "default", saveButtonBarState: "default",
webhook, webhook,
availableEvents: [],
}; };
storiesOf("Apps / Webhooks / Webhook details", module) storiesOf("Apps / Webhooks / Webhook details", module)
.addDecorator(Decorator) .addDecorator(Decorator)

View file

@ -11,6 +11,7 @@ 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 { IntrospectionNode } from "@dashboard/custom-apps/utils";
import { import {
WebhookDetailsFragment, WebhookDetailsFragment,
WebhookErrorFragment, WebhookErrorFragment,
@ -49,6 +50,7 @@ export interface WebhookDetailsPageProps {
webhook?: WebhookDetailsFragment | null; webhook?: WebhookDetailsFragment | null;
saveButtonBarState: ConfirmButtonTransitionState; saveButtonBarState: ConfirmButtonTransitionState;
onSubmit: (data: WebhookFormData) => SubmitPromise<any[]>; onSubmit: (data: WebhookFormData) => SubmitPromise<any[]>;
availableEvents: IntrospectionNode[];
} }
const WebhookDetailsPage: React.FC<WebhookDetailsPageProps> = ({ const WebhookDetailsPage: React.FC<WebhookDetailsPageProps> = ({
@ -58,6 +60,7 @@ const WebhookDetailsPage: React.FC<WebhookDetailsPageProps> = ({
webhook, webhook,
saveButtonBarState, saveButtonBarState,
onSubmit, onSubmit,
availableEvents,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigator(); const navigate = useNavigator();
@ -100,12 +103,14 @@ const WebhookDetailsPage: React.FC<WebhookDetailsPageProps> = ({
data, data,
query, query,
setQuery, setQuery,
availableEvents,
}); });
const handleAsyncEventsSelect = createAsyncEventsSelectHandler({ const handleAsyncEventsSelect = createAsyncEventsSelectHandler({
change, change,
data, data,
query, query,
setQuery, setQuery,
availableEvents,
}); });
return ( return (

View file

@ -6,27 +6,38 @@ import { ChangeEvent } from "@dashboard/hooks/useForm";
import { capitalize } from "@dashboard/misc"; import { capitalize } from "@dashboard/misc";
import { toggle } from "@dashboard/utils/lists"; import { toggle } from "@dashboard/utils/lists";
import { import {
DocumentNode,
FieldNode,
InlineFragmentNode, InlineFragmentNode,
ObjectFieldNode, ObjectFieldNode,
OperationDefinitionNode,
parse, parse,
print, print,
SelectionNode,
visit, visit,
} from "graphql"; } from "graphql";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import React, { Dispatch, SetStateAction } from "react"; import React, { Dispatch, SetStateAction } from "react";
import { WebhookFormData } from "./components/WebhookDetailsPage"; import { WebhookFormData } from "./components/WebhookDetailsPage";
import { filterSelectedAsyncEvents } from "./utils"; import { filterSelectedAsyncEvents, IntrospectionNode } from "./utils";
interface CreateSyncEventsSelectHandler { interface CreateSyncEventsSelectHandler {
change: (event: ChangeEvent, cb?: () => void) => void; change: (event: ChangeEvent, cb?: () => void) => void;
data: WebhookFormData; data: WebhookFormData;
query: string; query: string;
setQuery: Dispatch<SetStateAction<string>>; setQuery: Dispatch<SetStateAction<string>>;
availableEvents: IntrospectionNode[];
} }
export const createSyncEventsSelectHandler = export const createSyncEventsSelectHandler =
({ change, data, query, setQuery }: CreateSyncEventsSelectHandler) => ({
change,
data,
query,
setQuery,
availableEvents,
}: CreateSyncEventsSelectHandler) =>
(event: ChangeEvent) => { (event: ChangeEvent) => {
const { syncEvents, asyncEvents } = data; const { syncEvents, asyncEvents } = data;
const events = toggle(event.target.value, syncEvents, (a, b) => a === b); 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 { interface CreateAsyncEventsSelectHandler {
@ -58,10 +69,17 @@ interface CreateAsyncEventsSelectHandler {
data: WebhookFormData; data: WebhookFormData;
query: string; query: string;
setQuery: Dispatch<SetStateAction<string>>; setQuery: Dispatch<SetStateAction<string>>;
availableEvents: IntrospectionNode[];
} }
export const createAsyncEventsSelectHandler = export const createAsyncEventsSelectHandler =
({ change, data, query, setQuery }: CreateAsyncEventsSelectHandler) => ({
change,
data,
query,
setQuery,
availableEvents,
}: CreateAsyncEventsSelectHandler) =>
(event: ChangeEvent) => { (event: ChangeEvent) => {
const { syncEvents, asyncEvents } = data; const { syncEvents, asyncEvents } = data;
const events = toggle(event.target.value, asyncEvents, (a, b) => a === b); 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 = ( const enumToEventName = (value: string) =>
events: WebhookEventTypeAsyncEnum[] | WebhookEventTypeSyncEnum[], value
query: string, .toLowerCase()
setQuery: React.Dispatch<React.SetStateAction<string>>, .split("_")
) => { .map(chunk => capitalize(chunk))
if (events.length > 0 && query.length === 0) { .join("");
const event = events[0]
.toLowerCase()
.split("_")
.map(chunk => capitalize(chunk))
.join("");
interface HandleQuery {
events: WebhookEventTypeAsyncEnum[] | WebhookEventTypeSyncEnum[];
query: string;
setQuery: React.Dispatch<React.SetStateAction<string>>;
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( setQuery(
print(parse(`subscription { event { ... on ${event} { __typename } } }`)), print(
parse(
`subscription { event { ... on ${eventsNames[0]} { __typename } } }`,
),
),
); );
} }
if (query.length > 0) { if (query.length > 0) {
const ast = parse(query); try {
const ast = parse(query);
visit(ast, { const editedAst: DocumentNode = visit(ast, {
SelectionSet(node, _key, parent) { SelectionSet(node, _key, parent) {
if ((parent as ObjectFieldNode).name?.value === "event") { if ((parent as ObjectFieldNode).name?.value === "event") {
const queryEvents = node.selections.map( const queryEvents = node.selections.map(
selection => selection =>
(selection as InlineFragmentNode).typeCondition.name.value, (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; 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;
};

14
src/custom-apps/hooks.ts Normal file
View file

@ -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;
};

View file

@ -1,3 +1,4 @@
import { gql } from "@apollo/client";
import { WebhookEventTypeAsyncEnum, WebhookFragment } from "@dashboard/graphql"; import { WebhookEventTypeAsyncEnum, WebhookFragment } from "@dashboard/graphql";
export function isUnnamed(webhook: WebhookFragment | undefined): boolean { export function isUnnamed(webhook: WebhookFragment | undefined): boolean {
@ -15,3 +16,31 @@ export const filterSelectedAsyncEvents = (
} }
return asyncEvents; 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));

View file

@ -14,6 +14,7 @@ import { useIntl } from "react-intl";
import WebhookDetailsPage, { import WebhookDetailsPage, {
WebhookFormData, WebhookFormData,
} from "../components/WebhookDetailsPage"; } from "../components/WebhookDetailsPage";
import { useAvailableEvents } from "../hooks";
import { CustomAppUrls } from "../urls"; import { CustomAppUrls } from "../urls";
export interface CustomAppWebhookCreateProps { export interface CustomAppWebhookCreateProps {
@ -29,6 +30,8 @@ export const CustomAppWebhookCreate: React.FC<CustomAppWebhookCreateProps> = ({
const { data } = useAppQuery({ variables: { id: appId } }); const { data } = useAppQuery({ variables: { id: appId } });
const availableEvents = useAvailableEvents();
const [webhookCreate, webhookCreateOpts] = useWebhookCreateMutation({ const [webhookCreate, webhookCreateOpts] = useWebhookCreateMutation({
onCompleted: data => { onCompleted: data => {
const webhook = data.webhookCreate?.webhook; const webhook = data.webhookCreate?.webhook;
@ -80,6 +83,7 @@ export const CustomAppWebhookCreate: React.FC<CustomAppWebhookCreateProps> = ({
errors={webhookCreateOpts.data?.webhookCreate?.errors ?? []} errors={webhookCreateOpts.data?.webhookCreate?.errors ?? []}
onSubmit={handleSubmit} onSubmit={handleSubmit}
saveButtonBarState={webhookCreateOpts.status} saveButtonBarState={webhookCreateOpts.status}
availableEvents={availableEvents}
/> />
</> </>
); );

View file

@ -14,6 +14,7 @@ import { extractMutationErrors, getStringOrPlaceholder } from "../../misc";
import WebhookDetailsPage, { import WebhookDetailsPage, {
WebhookFormData, WebhookFormData,
} from "../components/WebhookDetailsPage"; } from "../components/WebhookDetailsPage";
import { useAvailableEvents } from "../hooks";
import { CustomAppUrls } from "../urls"; import { CustomAppUrls } from "../urls";
export interface CustomAppWebhookDetailsProps { export interface CustomAppWebhookDetailsProps {
@ -26,6 +27,8 @@ export const CustomAppWebhookDetails: React.FC<
const notify = useNotifier(); const notify = useNotifier();
const intl = useIntl(); const intl = useIntl();
const availableEvents = useAvailableEvents();
const { data: webhookDetails, loading } = useWebhookDetailsQuery({ const { data: webhookDetails, loading } = useWebhookDetailsQuery({
variables: { id }, variables: { id },
}); });
@ -86,6 +89,7 @@ export const CustomAppWebhookDetails: React.FC<
saveButtonBarState={webhookUpdateOpts.status} saveButtonBarState={webhookUpdateOpts.status}
webhook={webhook} webhook={webhook}
onSubmit={handleSubmit} onSubmit={handleSubmit}
availableEvents={availableEvents}
/> />
</> </>
); );