diff --git a/.changeset/chilly-pugs-fetch.md b/.changeset/chilly-pugs-fetch.md new file mode 100644 index 000000000..da9349422 --- /dev/null +++ b/.changeset/chilly-pugs-fetch.md @@ -0,0 +1,7 @@ +--- +"saleor-dashboard": minor +--- + +Added apps webhooks status to "Manage app" page. Now the page displays a list of all registered webhooks and information if its enabled or disabled. If Webhook has any pending or failed delivery attempt, they will be listed with a timestamp and status. + +The Manage App page was refreshed with borders to match layout of other Dashboard pages diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index d250bd41d..64d87347b 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -2259,6 +2259,9 @@ "context": "delete app", "string": "Deleting {name}, you will remove installation of the app. If you are paying for app subscription, remember to unsubscribe from the app in Saleor Marketplace. Are you sure you want to delete the app?" }, + "EY/jqC": { + "string": "Last delivery attempt:" + }, "EYkW1J": { "context": "checkbox label", "string": "Charge taxes for this channel" @@ -3663,6 +3666,9 @@ "context": "WarehouseSettings disabled warehouse label", "string": "Disabled" }, + "OFTsI1": { + "string": "Attempts:" + }, "OFW7nq": { "context": "placeholder", "string": "Search by page name, etc..." @@ -4262,6 +4268,9 @@ "context": "alert message", "string": "Orders cannot be placed in an inactive channel." }, + "SRMNCS": { + "string": "Pending & failed deliveries (last 10)" + }, "SSWFo8": { "context": "window title", "string": "Create Product Type" @@ -5086,6 +5095,9 @@ "context": "header", "string": "Edit Authorization Field" }, + "Xy48q5": { + "string": "All webhooks registered by this app. In case of failed webhook delivery, list of attempts is displayed." + }, "Xz/sDf": { "context": "Dry run items list item", "string": "Item:" @@ -5952,6 +5964,9 @@ "context": "Alert text", "string": "You are using an old version of filter presets. The following presets: {presetNames} must be updated to continue using filters" }, + "eKEL/g": { + "string": "Pending" + }, "eLJQSh": { "context": "column title gift card", "string": "Gift Card" @@ -5960,6 +5975,9 @@ "context": "button", "string": "Done" }, + "eQ7bCN": { + "string": "App Webhooks" + }, "eRqx44": { "string": "Search collections..." }, @@ -8277,6 +8295,9 @@ "context": "card title", "string": "Order Weight" }, + "vXCeIi": { + "string": "Failed" + }, "vXFPD6": { "context": "voucher discount type", "string": "Fixed Amount" diff --git a/src/apps/components/AppDetailsPage/AppDetailsPage.tsx b/src/apps/components/AppDetailsPage/AppDetailsPage.tsx index 5b0fb32a8..307d4ffee 100644 --- a/src/apps/components/AppDetailsPage/AppDetailsPage.tsx +++ b/src/apps/components/AppDetailsPage/AppDetailsPage.tsx @@ -4,6 +4,7 @@ import { Box, Text } from "@saleor/macaw-ui/next"; import React from "react"; import { ErrorBoundary } from "react-error-boundary"; +import { AppWebhooksDisplay } from "../AppWebhooksDisplay/AppWebhooksDisplay"; import { AboutCard } from "./AboutCard"; import { DataPrivacyCard } from "./DataPrivacyCard"; import Header from "./Header"; @@ -43,18 +44,43 @@ export const AppDetailsPage: React.FC = ({ onAppDeactivateOpen={onAppDeactivateOpen} onAppDeleteOpen={onAppDeleteOpen} /> - - - + + + + + + + + ); }; diff --git a/src/apps/components/AppDetailsPage/DataPrivacyCard.tsx b/src/apps/components/AppDetailsPage/DataPrivacyCard.tsx index e0dc4a2fa..fe6443bb3 100644 --- a/src/apps/components/AppDetailsPage/DataPrivacyCard.tsx +++ b/src/apps/components/AppDetailsPage/DataPrivacyCard.tsx @@ -1,4 +1,4 @@ -import ExternalLink from "@dashboard/components/ExternalLink"; +import { ExternalLinkNext } from "@dashboard/components/ExternalLink"; import Skeleton from "@dashboard/components/Skeleton"; import { Box, BoxProps, Text } from "@saleor/macaw-ui/next"; import React from "react"; @@ -29,9 +29,9 @@ export const DataPrivacyCard: React.FC = ({ if (dataPrivacyUrl) { return ( - + - + ); } diff --git a/src/apps/components/AppDetailsPage/HeaderOptions.tsx b/src/apps/components/AppDetailsPage/HeaderOptions.tsx index c4772931f..1e2045896 100644 --- a/src/apps/components/AppDetailsPage/HeaderOptions.tsx +++ b/src/apps/components/AppDetailsPage/HeaderOptions.tsx @@ -25,7 +25,7 @@ const HeaderOptions: React.FC = ({ const classes = useStyles(); return ( - +
= ({
-
); }; diff --git a/src/apps/components/AppWebhooksDisplay/AppWebhooksDisplay.tsx b/src/apps/components/AppWebhooksDisplay/AppWebhooksDisplay.tsx new file mode 100644 index 000000000..fe85ca2f4 --- /dev/null +++ b/src/apps/components/AppWebhooksDisplay/AppWebhooksDisplay.tsx @@ -0,0 +1,250 @@ +import { DateTime } from "@dashboard/components/Date"; +import { + EventDeliveryStatusEnum, + useAppWebhookDeliveriesQuery, +} from "@dashboard/graphql"; +import { + Accordion, + Box, + BoxProps, + Chip, + Skeleton, + Text, + ThemeTokensValues, +} from "@saleor/macaw-ui/next"; +import React from "react"; +import { useIntl } from "react-intl"; + +interface AppWebhooksDisplayProps extends BoxProps { + appId: string; +} + +const Wrapper = (boxProps: BoxProps) => { + const intl = useIntl(); + + return ( + + + {intl.formatMessage({ + defaultMessage: "App Webhooks", + id: "eQ7bCN", + })} + + + {intl.formatMessage({ + defaultMessage: + "All webhooks registered by this app. In case of failed webhook delivery, list of attempts is displayed.", + id: "Xy48q5", + })} + + {boxProps.children} + + ); +}; + +const mapDeliveryStatusToTextColor = ( + status: EventDeliveryStatusEnum, +): keyof ThemeTokensValues["colors"]["foreground"] => { + switch (status) { + case EventDeliveryStatusEnum.FAILED: + return "textCriticalDefault"; + case EventDeliveryStatusEnum.PENDING: + return "textBrandDefault"; + case EventDeliveryStatusEnum.SUCCESS: + return "text1Decorative"; + } +}; + +const mapDeliveryStatusToBackgroundColor = ( + status: EventDeliveryStatusEnum, +): keyof ThemeTokensValues["colors"]["background"] => { + switch (status) { + case EventDeliveryStatusEnum.FAILED: + return "surfaceCriticalSubdued"; + case EventDeliveryStatusEnum.PENDING: + return "surfaceNeutralHighlight"; + case EventDeliveryStatusEnum.SUCCESS: + return "surfaceBrandSubdued"; + } +}; + +const DeliveryStatusDisplay = ({ + status, +}: { + status: EventDeliveryStatusEnum; +}) => { + const { formatMessage } = useIntl(); + + switch (status) { + case EventDeliveryStatusEnum.FAILED: + return <>{formatMessage({ defaultMessage: "Failed", id: "vXCeIi" })}; + case EventDeliveryStatusEnum.PENDING: + return <>{formatMessage({ defaultMessage: "Pending", id: "eKEL/g" })}; + case EventDeliveryStatusEnum.SUCCESS: + return <>{formatMessage({ defaultMessage: "Success", id: "xrKHS6" })} ; + } + + throw new Error("Invalid EventDeliveryStatusEnum value"); +}; + +const StatusChip = ({ status }: { status: EventDeliveryStatusEnum }) => { + return ( + + + + + + ); +}; + +const DisabledWebhookChip = () => { + const { formatMessage } = useIntl(); + + return ( + + + {formatMessage({ + defaultMessage: "Disabled", + id: "tthToS", + })} + + + ); +}; + +export const AppWebhooksDisplay = ({ + appId, + ...boxProps +}: AppWebhooksDisplayProps) => { + const { formatMessage } = useIntl(); + + const { data: webhooksData, loading } = useAppWebhookDeliveriesQuery({ + variables: { appId }, + }); + + if (loading) { + return ( + + + + + + ); + } + + if (webhooksData?.app?.webhooks) { + return ( + + + {webhooksData.app.webhooks.map((wh, index) => { + const isLastWebhook = + index === (webhooksData?.app?.webhooks ?? []).length - 1; + + const events = [...wh.asyncEvents, ...wh.syncEvents] + .flatMap(e => e.name) + .join(", "); + + const eventDeliveries = wh.eventDeliveries?.edges ?? []; + + return ( + + + + {wh.name} + {!wh.isActive && } + + + {events} + + + {eventDeliveries.length > 0 && ( + + + + {formatMessage({ + defaultMessage: + "Pending & failed deliveries (last 10)", + id: "SRMNCS", + })} + + + + + {eventDeliveries.map(ed => { + const { createdAt } = ed.node; + const attempts = ed.node.attempts?.edges ?? []; + + const attemptsCount = attempts.length; + const lastAttemptDate = + attempts[attemptsCount - 1]?.node.createdAt; + + return ( + + + + + + + + + + {attempts.length > 0 && ( + + + {formatMessage({ + defaultMessage: "Attempts:", + id: "OFTsI1", + })}{" "} + + {attemptsCount} / 6 + + + + {formatMessage({ + defaultMessage: "Last delivery attempt:", + id: "EY/jqC", + })}{" "} + + + + )} + + ); + })} + + + )} + + ); + })} + + + ); + } + + return null; +}; diff --git a/src/apps/queries.ts b/src/apps/queries.ts index 42d0c5abd..8fbd58b71 100644 --- a/src/apps/queries.ts +++ b/src/apps/queries.ts @@ -87,3 +87,38 @@ export const extensionList = gql` `; export const EXTENSION_LIST_QUERY = "ExtensionList"; + +export const appWebhookDeliveries = gql` + query AppWebhookDeliveries($appId: ID!) { + app(id: $appId) { + webhooks { + id + name + isActive + syncEvents { + name + } + asyncEvents { + name + } + eventDeliveries(first: 10) { + edges { + node { + createdAt + status + eventType + attempts(first: 10) { + edges { + node { + createdAt + status + } + } + } + } + } + } + } + } + } +`; diff --git a/src/apps/views/AppManageView/AppManageView.tsx b/src/apps/views/AppManageView/AppManageView.tsx index 59f122aae..8ff441c54 100644 --- a/src/apps/views/AppManageView/AppManageView.tsx +++ b/src/apps/views/AppManageView/AppManageView.tsx @@ -7,7 +7,7 @@ import { useAppActivateMutation, useAppDeactivateMutation, useAppDeleteMutation, - useAppQuery, + useAppQuery } from "@dashboard/graphql"; import useNavigator from "@dashboard/hooks/useNavigator"; import useNotifier from "@dashboard/hooks/useNotifier"; diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index d4be210cd..b16a1eb3b 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -3836,6 +3836,68 @@ export function useExtensionListLazyQuery(baseOptions?: ApolloReactHooks.LazyQue export type ExtensionListQueryHookResult = ReturnType; export type ExtensionListLazyQueryHookResult = ReturnType; export type ExtensionListQueryResult = Apollo.QueryResult; +export const AppWebhookDeliveriesDocument = gql` + query AppWebhookDeliveries($appId: ID!) { + app(id: $appId) { + webhooks { + id + name + isActive + syncEvents { + name + } + asyncEvents { + name + } + eventDeliveries(first: 10) { + edges { + node { + createdAt + status + eventType + attempts(first: 10) { + edges { + node { + createdAt + status + } + } + } + } + } + } + } + } +} + `; + +/** + * __useAppWebhookDeliveriesQuery__ + * + * To run a query within a React component, call `useAppWebhookDeliveriesQuery` and pass it any options that fit your needs. + * When your component renders, `useAppWebhookDeliveriesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useAppWebhookDeliveriesQuery({ + * variables: { + * appId: // value for 'appId' + * }, + * }); + */ +export function useAppWebhookDeliveriesQuery(baseOptions: ApolloReactHooks.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return ApolloReactHooks.useQuery(AppWebhookDeliveriesDocument, options); + } +export function useAppWebhookDeliveriesLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return ApolloReactHooks.useLazyQuery(AppWebhookDeliveriesDocument, options); + } +export type AppWebhookDeliveriesQueryHookResult = ReturnType; +export type AppWebhookDeliveriesLazyQueryHookResult = ReturnType; +export type AppWebhookDeliveriesQueryResult = Apollo.QueryResult; export const AttributeBulkDeleteDocument = gql` mutation AttributeBulkDelete($ids: [ID!]!) { attributeBulkDelete(ids: $ids) { diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index 34064cce1..6d3cef870 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -8253,6 +8253,13 @@ export type ExtensionListQueryVariables = Exact<{ export type ExtensionListQuery = { __typename: 'Query', appExtensions: { __typename: 'AppExtensionCountableConnection', edges: Array<{ __typename: 'AppExtensionCountableEdge', node: { __typename: 'AppExtension', id: string, label: string, url: string, mount: AppExtensionMountEnum, target: AppExtensionTargetEnum, accessToken: string | null, permissions: Array<{ __typename: 'Permission', code: PermissionEnum }>, app: { __typename: 'App', id: string, appUrl: string | null } } }> } | null }; +export type AppWebhookDeliveriesQueryVariables = Exact<{ + appId: Scalars['ID']; +}>; + + +export type AppWebhookDeliveriesQuery = { __typename: 'Query', app: { __typename: 'App', webhooks: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, syncEvents: Array<{ __typename: 'WebhookEventSync', name: string }>, asyncEvents: Array<{ __typename: 'WebhookEventAsync', name: string }>, eventDeliveries: { __typename: 'EventDeliveryCountableConnection', edges: Array<{ __typename: 'EventDeliveryCountableEdge', node: { __typename: 'EventDelivery', createdAt: any, status: EventDeliveryStatusEnum, eventType: WebhookEventTypeEnum, attempts: { __typename: 'EventDeliveryAttemptCountableConnection', edges: Array<{ __typename: 'EventDeliveryAttemptCountableEdge', node: { __typename: 'EventDeliveryAttempt', createdAt: any, status: EventDeliveryStatusEnum } }> } | null } }> } | null }> | null } | null }; + export type AttributeBulkDeleteMutationVariables = Exact<{ ids: Array | Scalars['ID']; }>;