From 7474935ef138724979d180cdd4352510d85f0e46 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Wed, 12 Feb 2025 13:35:57 +0100 Subject: [PATCH 01/15] show alert if there are failed/pending deliveries --- .changeset/modern-pianos-sneeze.md | 5 ++ .../components/AppAlerts/SidebarAppAlert.tsx | 58 ++++++++++++ src/apps/components/AppAlerts/queries.ts | 28 ++++++ .../AppAlerts/useAllAppsAlert.test.ts | 89 +++++++++++++++++++ .../components/AppAlerts/useAllAppsAlert.ts | 47 ++++++++++ src/components/Sidebar/menu/SingleItem.tsx | 6 ++ .../Sidebar/menu/hooks/useMenuStructure.tsx | 2 + src/components/Sidebar/menu/types.ts | 4 +- src/graphql/hooks.generated.ts | 53 +++++++++++ src/graphql/types.generated.ts | 5 ++ src/icons/ExclamationIcon.tsx | 34 +++++++ src/icons/ExclamationIconFilled.tsx | 35 ++++++++ src/index.css | 9 +- 13 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 .changeset/modern-pianos-sneeze.md create mode 100644 src/apps/components/AppAlerts/SidebarAppAlert.tsx create mode 100644 src/apps/components/AppAlerts/queries.ts create mode 100644 src/apps/components/AppAlerts/useAllAppsAlert.test.ts create mode 100644 src/apps/components/AppAlerts/useAllAppsAlert.ts create mode 100644 src/icons/ExclamationIcon.tsx create mode 100644 src/icons/ExclamationIconFilled.tsx diff --git a/.changeset/modern-pianos-sneeze.md b/.changeset/modern-pianos-sneeze.md new file mode 100644 index 00000000000..ea335564856 --- /dev/null +++ b/.changeset/modern-pianos-sneeze.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +"Apps" link in sidebar now shows an alert if one or more apps have failed or pending webhook deliveries. This means you can see if there are issues with your apps without having to enter app details. diff --git a/src/apps/components/AppAlerts/SidebarAppAlert.tsx b/src/apps/components/AppAlerts/SidebarAppAlert.tsx new file mode 100644 index 00000000000..cf97f48727e --- /dev/null +++ b/src/apps/components/AppAlerts/SidebarAppAlert.tsx @@ -0,0 +1,58 @@ +import { AppSections } from "@dashboard/apps/urls"; +import { ExclamationIcon } from "@dashboard/icons/ExclamationIcon"; +import { ExclamationIconFilled } from "@dashboard/icons/ExclamationIconFilled"; +import { Box, Text, Tooltip } from "@saleor/macaw-ui-next"; +import React, { useState } from "react"; +import { FormattedMessage } from "react-intl"; +import { Link } from "react-router-dom"; + +import { useAllAppsAlert } from "./useAllAppsAlert"; + +const ExclamationIconComponent = () => { + const [isHovered, setIsHovered] = useState(false); + const colorLighter = "#FFD87E"; + const colorDefault = "#FFB84E"; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {isHovered ? : } + + ); +}; + +export const SidebarAppAlert = () => { + const { failed, pending } = useAllAppsAlert(); + const hasIssues = failed > 0 || pending > 0; + + if (!hasIssues) { + return null; + } + + return ( + + + + + + + + + + , + }} + /> + + + + ); +}; diff --git a/src/apps/components/AppAlerts/queries.ts b/src/apps/components/AppAlerts/queries.ts new file mode 100644 index 00000000000..14cac8e6774 --- /dev/null +++ b/src/apps/components/AppAlerts/queries.ts @@ -0,0 +1,28 @@ +import { gql } from "@apollo/client"; + +export const appFailedPendingWebhooks = gql` + query AppFailedPendingWebhooks { + apps(first: 50) { + edges { + node { + webhooks { + failedDelivers: eventDeliveries(first: 1, filter: { status: FAILED }) { + edges { + node { + id + } + } + } + pendingDelivers: eventDeliveries(first: 1, filter: { status: PENDING }) { + edges { + node { + id + } + } + } + } + } + } + } + } +`; diff --git a/src/apps/components/AppAlerts/useAllAppsAlert.test.ts b/src/apps/components/AppAlerts/useAllAppsAlert.test.ts new file mode 100644 index 00000000000..b4d6a981024 --- /dev/null +++ b/src/apps/components/AppAlerts/useAllAppsAlert.test.ts @@ -0,0 +1,89 @@ +import { useUserPermissions } from "@dashboard/auth/hooks/useUserPermissions"; +import { PermissionEnum, useAppFailedPendingWebhooksQuery } from "@dashboard/graphql"; +import { renderHook } from "@testing-library/react-hooks"; + +import { useAllAppsAlert } from "./useAllAppsAlert"; + +jest.mock("@dashboard/auth/hooks/useUserPermissions"); +jest.mock("@dashboard/graphql"); + +describe("useAllAppsAlert", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return default counts when user has no permissions", () => { + (useUserPermissions as jest.Mock).mockReturnValue([]); + (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); + + const { result } = renderHook(() => useAllAppsAlert()); + + expect(result.current).toEqual({ failed: 0, pending: 0 }); + expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ + skip: true, + }); + }); + + it("should count webhooks correctly when user has permissions", () => { + (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); + (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ + data: { + apps: { + edges: [ + { + node: { + webhooks: [ + { + failedDelivers: { edges: [1, 2] }, + pendingDelivers: { edges: [1] }, + }, + { + failedDelivers: { edges: [1] }, + pendingDelivers: { edges: [1, 2] }, + }, + ], + }, + }, + ], + }, + }, + }); + + const { result } = renderHook(() => useAllAppsAlert()); + + expect(result.current).toEqual({ failed: 3, pending: 3 }); + expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ + skip: false, + }); + }); + + it("should handle null webhook data", () => { + (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); + (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ + data: { + apps: { + edges: [ + { + node: { + webhooks: null, + }, + }, + ], + }, + }, + }); + + const { result } = renderHook(() => useAllAppsAlert()); + + expect(result.current).toEqual({ failed: 0, pending: 0 }); + }); + + it("should handle undefined permissions", () => { + (useUserPermissions as jest.Mock).mockReturnValue(undefined); + (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); + + const { result } = renderHook(() => useAllAppsAlert()); + + expect(result.current).toEqual({ failed: 0, pending: 0 }); + }); +}); diff --git a/src/apps/components/AppAlerts/useAllAppsAlert.ts b/src/apps/components/AppAlerts/useAllAppsAlert.ts new file mode 100644 index 00000000000..23715b3881f --- /dev/null +++ b/src/apps/components/AppAlerts/useAllAppsAlert.ts @@ -0,0 +1,47 @@ +import { useUserPermissions } from "@dashboard/auth/hooks/useUserPermissions"; +import { PermissionEnum, useAppFailedPendingWebhooksQuery } from "@dashboard/graphql"; + +const requiredPermissions = [PermissionEnum.MANAGE_APPS]; + +interface FailedWebhooksCount { + failed: number; + pending: number; +} + +const defaultFailedWebhooksCount: FailedWebhooksCount = { + failed: 0, + pending: 0, +}; + +export const useAllAppsAlert = (): FailedWebhooksCount => { + const permissions = useUserPermissions(); + const hasRequiredPermissions = requiredPermissions.some(permission => + permissions?.map(e => e.code)?.includes(permission), + ); + + const { data } = useAppFailedPendingWebhooksQuery({ + skip: !hasRequiredPermissions, + }); + + const failedWebhooksCount = + data?.apps?.edges.reduce((acc, app) => { + const failedWebhooks = + app.node.webhooks?.reduce( + (acc, webhook) => acc + (webhook?.failedDelivers?.edges?.length ?? 0), + 0, + ) ?? 0; + + const pendingWebhooks = + app.node.webhooks?.reduce( + (acc, webhook) => acc + (webhook?.pendingDelivers?.edges?.length ?? 0), + 0, + ) ?? 0; + + return { + failed: acc.failed + failedWebhooks, + pending: acc.pending + pendingWebhooks, + }; + }, defaultFailedWebhooksCount) ?? defaultFailedWebhooksCount; + + return failedWebhooksCount; +}; diff --git a/src/components/Sidebar/menu/SingleItem.tsx b/src/components/Sidebar/menu/SingleItem.tsx index 83aec0e4395..7deef137bad 100644 --- a/src/components/Sidebar/menu/SingleItem.tsx +++ b/src/components/Sidebar/menu/SingleItem.tsx @@ -28,6 +28,7 @@ export const SingleItem: React.FC = ({ menuItem }) => { active={active} onClick={handleMenuItemClick} data-test-id={`menu-item-label-${menuItem.id}`} + position="relative" > = ({ menuItem }) => { + {menuItem.endAdornment && ( + + {menuItem.endAdornment} + + )} ); }; diff --git a/src/components/Sidebar/menu/hooks/useMenuStructure.tsx b/src/components/Sidebar/menu/hooks/useMenuStructure.tsx index 39f0383558a..d2e983377fe 100644 --- a/src/components/Sidebar/menu/hooks/useMenuStructure.tsx +++ b/src/components/Sidebar/menu/hooks/useMenuStructure.tsx @@ -1,3 +1,4 @@ +import { SidebarAppAlert } from "@dashboard/apps/components/AppAlerts/SidebarAppAlert"; import { extensionMountPoints, useExtensions } from "@dashboard/apps/hooks/useExtensions"; import { AppPaths } from "@dashboard/apps/urls"; import { useUser } from "@dashboard/auth"; @@ -48,6 +49,7 @@ export function useMenuStructure() { id: "apps", url: AppPaths.appListPath, type: "item", + endAdornment: , }); const menuItems: SidebarMenuItem[] = [ { diff --git a/src/components/Sidebar/menu/types.ts b/src/components/Sidebar/menu/types.ts index 17dccaae812..0fa8571b451 100644 --- a/src/components/Sidebar/menu/types.ts +++ b/src/components/Sidebar/menu/types.ts @@ -1,5 +1,6 @@ import { PermissionEnum } from "@dashboard/graphql"; import { Sprinkles } from "@saleor/macaw-ui-next"; +import { ReactNode } from "react"; export interface SidebarMenuItem { label?: string; @@ -7,8 +8,9 @@ export interface SidebarMenuItem { url?: string; permissions?: PermissionEnum[]; type: "item" | "itemGroup" | "divider"; - icon?: React.ReactNode; + icon?: ReactNode; onClick?: () => void; children?: SidebarMenuItem[]; paddingY?: Sprinkles["paddingY"]; + endAdornment?: ReactNode; } diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 981d830487e..b44631c3535 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -3461,6 +3461,59 @@ export const WebhookDetailsFragmentDoc = gql` customHeaders } ${WebhookFragmentDoc}`; +export const AppFailedPendingWebhooksDocument = gql` + query AppFailedPendingWebhooks { + apps(first: 50) { + edges { + node { + webhooks { + failedDelivers: eventDeliveries(first: 1, filter: {status: FAILED}) { + edges { + node { + id + } + } + } + pendingDelivers: eventDeliveries(first: 1, filter: {status: PENDING}) { + edges { + node { + id + } + } + } + } + } + } + } +} + `; + +/** + * __useAppFailedPendingWebhooksQuery__ + * + * To run a query within a React component, call `useAppFailedPendingWebhooksQuery` and pass it any options that fit your needs. + * When your component renders, `useAppFailedPendingWebhooksQuery` 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 } = useAppFailedPendingWebhooksQuery({ + * variables: { + * }, + * }); + */ +export function useAppFailedPendingWebhooksQuery(baseOptions?: ApolloReactHooks.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return ApolloReactHooks.useQuery(AppFailedPendingWebhooksDocument, options); + } +export function useAppFailedPendingWebhooksLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return ApolloReactHooks.useLazyQuery(AppFailedPendingWebhooksDocument, options); + } +export type AppFailedPendingWebhooksQueryHookResult = ReturnType; +export type AppFailedPendingWebhooksLazyQueryHookResult = ReturnType; +export type AppFailedPendingWebhooksQueryResult = Apollo.QueryResult; export const AppCreateDocument = gql` mutation AppCreate($input: AppInput!, $hasManagedAppsPermission: Boolean = true) { appCreate(input: $input) { diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index 189dde7f8c7..b2822d92309 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -8929,6 +8929,11 @@ export enum WeightUnitsEnum { TONNE = 'TONNE' } +export type AppFailedPendingWebhooksQueryVariables = Exact<{ [key: string]: never; }>; + + +export type AppFailedPendingWebhooksQuery = { __typename: 'Query', apps: { __typename: 'AppCountableConnection', edges: Array<{ __typename: 'AppCountableEdge', node: { __typename: 'App', webhooks: Array<{ __typename: 'Webhook', failedDelivers: { __typename: 'EventDeliveryCountableConnection', edges: Array<{ __typename: 'EventDeliveryCountableEdge', node: { __typename: 'EventDelivery', id: string } }> } | null, pendingDelivers: { __typename: 'EventDeliveryCountableConnection', edges: Array<{ __typename: 'EventDeliveryCountableEdge', node: { __typename: 'EventDelivery', id: string } }> } | null }> | null } }> } | null }; + export type AppCreateMutationVariables = Exact<{ input: AppInput; hasManagedAppsPermission?: InputMaybe; diff --git a/src/icons/ExclamationIcon.tsx b/src/icons/ExclamationIcon.tsx new file mode 100644 index 00000000000..e554e375eef --- /dev/null +++ b/src/icons/ExclamationIcon.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +export const ExclamationIcon = () => ( + + + + + + + + + + + + +); diff --git a/src/icons/ExclamationIconFilled.tsx b/src/icons/ExclamationIconFilled.tsx new file mode 100644 index 00000000000..fb3d89798ec --- /dev/null +++ b/src/icons/ExclamationIconFilled.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +export const ExclamationIconFilled = () => ( + + + + + + + + + + + + +); diff --git a/src/index.css b/src/index.css index fb41c78883b..b1b8ce79799 100644 --- a/src/index.css +++ b/src/index.css @@ -75,7 +75,6 @@ body { border: none; } - .conditional-metadata label { border: none; } @@ -85,7 +84,7 @@ body { } @media (min-height: 900px) { - .scrollArea { - max-height: 600px; - } -} \ No newline at end of file + .scrollArea { + max-height: 600px; + } +} From eea1f001ccca3ade428ce05cf0b46dc866bb4aad Mon Sep 17 00:00:00 2001 From: Wojciech Date: Wed, 12 Feb 2025 15:32:32 +0100 Subject: [PATCH 02/15] i18n --- locale/defaultMessages.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index fa5d330a37b..85b6c8cc01c 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1017,6 +1017,9 @@ "4LRapg": { "string": "Discount removed" }, + "4MIO2H": { + "string": "Issues found.{break}Review app alerts." + }, "4PlW0w": { "context": "tracking number", "string": "Tracking Number: {trackingNumber}" From 93a4613b90fc209b9e54427c91c21eba9a4fe8d1 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Wed, 12 Feb 2025 16:04:38 +0100 Subject: [PATCH 03/15] useMemo and booleans --- .../components/AppAlerts/SidebarAppAlert.tsx | 4 +- .../AppAlerts/useAllAppsAlert.test.ts | 64 +++++++++---------- .../components/AppAlerts/useAllAppsAlert.ts | 58 +++++++++-------- 3 files changed, 66 insertions(+), 60 deletions(-) diff --git a/src/apps/components/AppAlerts/SidebarAppAlert.tsx b/src/apps/components/AppAlerts/SidebarAppAlert.tsx index cf97f48727e..b5eecd72f43 100644 --- a/src/apps/components/AppAlerts/SidebarAppAlert.tsx +++ b/src/apps/components/AppAlerts/SidebarAppAlert.tsx @@ -27,8 +27,8 @@ const ExclamationIconComponent = () => { }; export const SidebarAppAlert = () => { - const { failed, pending } = useAllAppsAlert(); - const hasIssues = failed > 0 || pending > 0; + const { hasFailed, hasPending } = useAllAppsAlert(); + const hasIssues = hasFailed || hasPending; if (!hasIssues) { return null; diff --git a/src/apps/components/AppAlerts/useAllAppsAlert.test.ts b/src/apps/components/AppAlerts/useAllAppsAlert.test.ts index b4d6a981024..42547ec8867 100644 --- a/src/apps/components/AppAlerts/useAllAppsAlert.test.ts +++ b/src/apps/components/AppAlerts/useAllAppsAlert.test.ts @@ -12,13 +12,43 @@ describe("useAllAppsAlert", () => { jest.clearAllMocks(); }); + it("should handle null webhook data", () => { + (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); + (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ + data: { + apps: { + edges: [ + { + node: { + webhooks: null, + }, + }, + ], + }, + }, + }); + + const { result } = renderHook(() => useAllAppsAlert()); + + expect(result.current).toEqual({ hasFailed: false, hasPending: false }); + }); + + it("should handle undefined permissions", () => { + (useUserPermissions as jest.Mock).mockReturnValue(undefined); + (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); + + const { result } = renderHook(() => useAllAppsAlert()); + + expect(result.current).toEqual({ hasFailed: false, hasPending: false }); + }); + it("should return default counts when user has no permissions", () => { (useUserPermissions as jest.Mock).mockReturnValue([]); (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); const { result } = renderHook(() => useAllAppsAlert()); - expect(result.current).toEqual({ failed: 0, pending: 0 }); + expect(result.current).toEqual({ hasFailed: false, hasPending: false }); expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ skip: true, }); @@ -51,39 +81,9 @@ describe("useAllAppsAlert", () => { const { result } = renderHook(() => useAllAppsAlert()); - expect(result.current).toEqual({ failed: 3, pending: 3 }); + expect(result.current).toEqual({ hasFailed: true, hasPending: true }); expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ skip: false, }); }); - - it("should handle null webhook data", () => { - (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); - (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ - data: { - apps: { - edges: [ - { - node: { - webhooks: null, - }, - }, - ], - }, - }, - }); - - const { result } = renderHook(() => useAllAppsAlert()); - - expect(result.current).toEqual({ failed: 0, pending: 0 }); - }); - - it("should handle undefined permissions", () => { - (useUserPermissions as jest.Mock).mockReturnValue(undefined); - (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); - - const { result } = renderHook(() => useAllAppsAlert()); - - expect(result.current).toEqual({ failed: 0, pending: 0 }); - }); }); diff --git a/src/apps/components/AppAlerts/useAllAppsAlert.ts b/src/apps/components/AppAlerts/useAllAppsAlert.ts index 23715b3881f..a2ff4b5736c 100644 --- a/src/apps/components/AppAlerts/useAllAppsAlert.ts +++ b/src/apps/components/AppAlerts/useAllAppsAlert.ts @@ -1,16 +1,17 @@ import { useUserPermissions } from "@dashboard/auth/hooks/useUserPermissions"; import { PermissionEnum, useAppFailedPendingWebhooksQuery } from "@dashboard/graphql"; +import { useMemo } from "react"; const requiredPermissions = [PermissionEnum.MANAGE_APPS]; interface FailedWebhooksCount { - failed: number; - pending: number; + hasFailed: boolean; + hasPending: boolean; } -const defaultFailedWebhooksCount: FailedWebhooksCount = { - failed: 0, - pending: 0, +const defaultFailedWebhooksInfo: FailedWebhooksCount = { + hasFailed: false, + hasPending: false, }; export const useAllAppsAlert = (): FailedWebhooksCount => { @@ -23,25 +24,30 @@ export const useAllAppsAlert = (): FailedWebhooksCount => { skip: !hasRequiredPermissions, }); - const failedWebhooksCount = - data?.apps?.edges.reduce((acc, app) => { - const failedWebhooks = - app.node.webhooks?.reduce( - (acc, webhook) => acc + (webhook?.failedDelivers?.edges?.length ?? 0), - 0, - ) ?? 0; - - const pendingWebhooks = - app.node.webhooks?.reduce( - (acc, webhook) => acc + (webhook?.pendingDelivers?.edges?.length ?? 0), - 0, - ) ?? 0; - - return { - failed: acc.failed + failedWebhooks, - pending: acc.pending + pendingWebhooks, - }; - }, defaultFailedWebhooksCount) ?? defaultFailedWebhooksCount; - - return failedWebhooksCount; + const failedWebhooksInfo = useMemo( + () => + data?.apps?.edges.reduce((acc, app) => { + const webhookInfo = defaultFailedWebhooksInfo; + + app.node.webhooks?.forEach(webhook => { + const { failedDelivers, pendingDelivers } = webhook; + + if (failedDelivers && failedDelivers.edges?.length > 0) { + webhookInfo.hasFailed = true; + } + + if (pendingDelivers && pendingDelivers.edges?.length > 0) { + webhookInfo.hasPending = true; + } + }); + + return { + hasFailed: webhookInfo.hasFailed || acc.hasFailed, + hasPending: webhookInfo.hasPending || acc.hasPending, + }; + }, defaultFailedWebhooksInfo) ?? defaultFailedWebhooksInfo, + [data], + ); + + return failedWebhooksInfo; }; From 18ade3f1fdc9552f4ac1b4f4248716ecf3e9d661 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Wed, 12 Feb 2025 16:58:28 +0100 Subject: [PATCH 04/15] get latest failed/pending event --- src/apps/components/AppAlerts/queries.ts | 12 ++++++++++-- .../components/AppAlerts/useAllAppsAlert.test.ts | 11 +++++++++++ src/graphql/hooks.generated.ts | 12 ++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/apps/components/AppAlerts/queries.ts b/src/apps/components/AppAlerts/queries.ts index 14cac8e6774..22d8d386c19 100644 --- a/src/apps/components/AppAlerts/queries.ts +++ b/src/apps/components/AppAlerts/queries.ts @@ -6,14 +6,22 @@ export const appFailedPendingWebhooks = gql` edges { node { webhooks { - failedDelivers: eventDeliveries(first: 1, filter: { status: FAILED }) { + failedDelivers: eventDeliveries( + first: 1 + filter: { status: FAILED } + sortBy: { field: CREATED_AT, direction: DESC } + ) { edges { node { id } } } - pendingDelivers: eventDeliveries(first: 1, filter: { status: PENDING }) { + pendingDelivers: eventDeliveries( + first: 1 + filter: { status: PENDING } + sortBy: { field: CREATED_AT, direction: DESC } + ) { edges { node { id diff --git a/src/apps/components/AppAlerts/useAllAppsAlert.test.ts b/src/apps/components/AppAlerts/useAllAppsAlert.test.ts index 42547ec8867..306c52ead29 100644 --- a/src/apps/components/AppAlerts/useAllAppsAlert.test.ts +++ b/src/apps/components/AppAlerts/useAllAppsAlert.test.ts @@ -13,6 +13,7 @@ describe("useAllAppsAlert", () => { }); it("should handle null webhook data", () => { + // Arrange (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: { @@ -28,26 +29,33 @@ describe("useAllAppsAlert", () => { }, }); + // Act const { result } = renderHook(() => useAllAppsAlert()); + // Assert expect(result.current).toEqual({ hasFailed: false, hasPending: false }); }); it("should handle undefined permissions", () => { + // Arrange (useUserPermissions as jest.Mock).mockReturnValue(undefined); (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); + // Act const { result } = renderHook(() => useAllAppsAlert()); expect(result.current).toEqual({ hasFailed: false, hasPending: false }); }); it("should return default counts when user has no permissions", () => { + // Arrange (useUserPermissions as jest.Mock).mockReturnValue([]); (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); + // Act const { result } = renderHook(() => useAllAppsAlert()); + // Assert expect(result.current).toEqual({ hasFailed: false, hasPending: false }); expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ skip: true, @@ -55,6 +63,7 @@ describe("useAllAppsAlert", () => { }); it("should count webhooks correctly when user has permissions", () => { + // Arrange (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: { @@ -79,8 +88,10 @@ describe("useAllAppsAlert", () => { }, }); + // Act const { result } = renderHook(() => useAllAppsAlert()); + // Assert expect(result.current).toEqual({ hasFailed: true, hasPending: true }); expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ skip: false, diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index b44631c3535..96541fe7730 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -3467,14 +3467,22 @@ export const AppFailedPendingWebhooksDocument = gql` edges { node { webhooks { - failedDelivers: eventDeliveries(first: 1, filter: {status: FAILED}) { + failedDelivers: eventDeliveries( + first: 1 + filter: {status: FAILED} + sortBy: {field: CREATED_AT, direction: DESC} + ) { edges { node { id } } } - pendingDelivers: eventDeliveries(first: 1, filter: {status: PENDING}) { + pendingDelivers: eventDeliveries( + first: 1 + filter: {status: PENDING} + sortBy: {field: CREATED_AT, direction: DESC} + ) { edges { node { id From b54b6f75c1f30a0129c8d31d29d3e5490377eac5 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Thu, 13 Feb 2025 11:38:13 +0100 Subject: [PATCH 05/15] check attempts --- src/apps/components/AppAlerts/queries.ts | 8 ++- .../AppAlerts/useAllAppsAlert.test.ts | 66 +++++++++++++++++-- .../components/AppAlerts/useAllAppsAlert.ts | 34 ++++++++-- src/graphql/hooks.generated.ts | 8 ++- src/graphql/types.generated.ts | 2 +- 5 files changed, 104 insertions(+), 14 deletions(-) diff --git a/src/apps/components/AppAlerts/queries.ts b/src/apps/components/AppAlerts/queries.ts index 22d8d386c19..0f3b7b22df5 100644 --- a/src/apps/components/AppAlerts/queries.ts +++ b/src/apps/components/AppAlerts/queries.ts @@ -24,7 +24,13 @@ export const appFailedPendingWebhooks = gql` ) { edges { node { - id + attempts(first: 6, sortBy: { field: CREATED_AT, direction: DESC }) { + edges { + node { + status + } + } + } } } } diff --git a/src/apps/components/AppAlerts/useAllAppsAlert.test.ts b/src/apps/components/AppAlerts/useAllAppsAlert.test.ts index 306c52ead29..3d9384e3ab8 100644 --- a/src/apps/components/AppAlerts/useAllAppsAlert.test.ts +++ b/src/apps/components/AppAlerts/useAllAppsAlert.test.ts @@ -62,7 +62,65 @@ describe("useAllAppsAlert", () => { }); }); - it("should count webhooks correctly when user has permissions", () => { + it("should check webhooks correctly for pending deliveries when user has permissions", () => { + // Arrange + (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); + (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ + data: { + apps: { + edges: [ + { + node: { + webhooks: [ + { + failedDelivers: { edges: [] }, + pendingDelivers: { + edges: [ + { + node: { + attempts: { + edges: [{ node: { status: "FAILED" } }], + }, + }, + }, + ], + }, + }, + { + failedDelivers: { edges: [] }, + pendingDelivers: { + edges: [ + { + node: { + attempts: { + edges: [{ node: { status: "FAILED" } }], + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }); + + // Act + const { result } = renderHook(() => useAllAppsAlert()); + + // rerender(); + + // Assert + expect(result.current.hasPending).toEqual(true); + expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ + skip: false, + }); + }); + + it("should check webhooks correctly when user has permissions", () => { // Arrange (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ @@ -74,11 +132,11 @@ describe("useAllAppsAlert", () => { webhooks: [ { failedDelivers: { edges: [1, 2] }, - pendingDelivers: { edges: [1] }, + pendingDelivers: [], }, { failedDelivers: { edges: [1] }, - pendingDelivers: { edges: [1, 2] }, + pendingDelivers: [], }, ], }, @@ -92,7 +150,7 @@ describe("useAllAppsAlert", () => { const { result } = renderHook(() => useAllAppsAlert()); // Assert - expect(result.current).toEqual({ hasFailed: true, hasPending: true }); + expect(result.current.hasFailed).toEqual(true); expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ skip: false, }); diff --git a/src/apps/components/AppAlerts/useAllAppsAlert.ts b/src/apps/components/AppAlerts/useAllAppsAlert.ts index a2ff4b5736c..7953ecdbefd 100644 --- a/src/apps/components/AppAlerts/useAllAppsAlert.ts +++ b/src/apps/components/AppAlerts/useAllAppsAlert.ts @@ -1,19 +1,41 @@ import { useUserPermissions } from "@dashboard/auth/hooks/useUserPermissions"; -import { PermissionEnum, useAppFailedPendingWebhooksQuery } from "@dashboard/graphql"; +import { + AppFailedPendingWebhooksQuery, + EventDeliveryStatusEnum, + PermissionEnum, + useAppFailedPendingWebhooksQuery, +} from "@dashboard/graphql"; import { useMemo } from "react"; -const requiredPermissions = [PermissionEnum.MANAGE_APPS]; - +type Webhook = NonNullable< + NonNullable["edges"][0]["node"]["webhooks"] +>[0]; interface FailedWebhooksCount { hasFailed: boolean; hasPending: boolean; } +const requiredPermissions = [PermissionEnum.MANAGE_APPS]; + const defaultFailedWebhooksInfo: FailedWebhooksCount = { hasFailed: false, hasPending: false, }; +const hasFailedCheck = (webhook: Webhook) => + webhook.failedDelivers && webhook.failedDelivers?.edges?.length > 0; +const hasPendingCheck = (webhook: Webhook) => { + const preliminaryCheck = webhook.pendingDelivers && webhook.pendingDelivers?.edges?.length > 0; + + if (!preliminaryCheck) return false; + + return webhook.pendingDelivers?.edges.some(edge => + edge.node?.attempts?.edges.some( + attempt => attempt.node.status === EventDeliveryStatusEnum.FAILED, + ), + ); +}; + export const useAllAppsAlert = (): FailedWebhooksCount => { const permissions = useUserPermissions(); const hasRequiredPermissions = requiredPermissions.some(permission => @@ -30,13 +52,11 @@ export const useAllAppsAlert = (): FailedWebhooksCount => { const webhookInfo = defaultFailedWebhooksInfo; app.node.webhooks?.forEach(webhook => { - const { failedDelivers, pendingDelivers } = webhook; - - if (failedDelivers && failedDelivers.edges?.length > 0) { + if (hasFailedCheck(webhook)) { webhookInfo.hasFailed = true; } - if (pendingDelivers && pendingDelivers.edges?.length > 0) { + if (hasPendingCheck(webhook)) { webhookInfo.hasPending = true; } }); diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 96541fe7730..5efca3a3e17 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -3485,7 +3485,13 @@ export const AppFailedPendingWebhooksDocument = gql` ) { edges { node { - id + attempts(first: 6, sortBy: {field: CREATED_AT, direction: DESC}) { + edges { + node { + status + } + } + } } } } diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index b2822d92309..df34a6ae7c2 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -8932,7 +8932,7 @@ export enum WeightUnitsEnum { export type AppFailedPendingWebhooksQueryVariables = Exact<{ [key: string]: never; }>; -export type AppFailedPendingWebhooksQuery = { __typename: 'Query', apps: { __typename: 'AppCountableConnection', edges: Array<{ __typename: 'AppCountableEdge', node: { __typename: 'App', webhooks: Array<{ __typename: 'Webhook', failedDelivers: { __typename: 'EventDeliveryCountableConnection', edges: Array<{ __typename: 'EventDeliveryCountableEdge', node: { __typename: 'EventDelivery', id: string } }> } | null, pendingDelivers: { __typename: 'EventDeliveryCountableConnection', edges: Array<{ __typename: 'EventDeliveryCountableEdge', node: { __typename: 'EventDelivery', id: string } }> } | null }> | null } }> } | null }; +export type AppFailedPendingWebhooksQuery = { __typename: 'Query', apps: { __typename: 'AppCountableConnection', edges: Array<{ __typename: 'AppCountableEdge', node: { __typename: 'App', webhooks: Array<{ __typename: 'Webhook', failedDelivers: { __typename: 'EventDeliveryCountableConnection', edges: Array<{ __typename: 'EventDeliveryCountableEdge', node: { __typename: 'EventDelivery', id: string } }> } | null, pendingDelivers: { __typename: 'EventDeliveryCountableConnection', edges: Array<{ __typename: 'EventDeliveryCountableEdge', node: { __typename: 'EventDelivery', attempts: { __typename: 'EventDeliveryAttemptCountableConnection', edges: Array<{ __typename: 'EventDeliveryAttemptCountableEdge', node: { __typename: 'EventDeliveryAttempt', status: EventDeliveryStatusEnum } }> } | null } }> } | null }> | null } }> } | null }; export type AppCreateMutationVariables = Exact<{ input: AppInput; From f2d5143844e531487aae845e461399f2ca3c7d67 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Thu, 13 Feb 2025 12:05:01 +0100 Subject: [PATCH 06/15] add feature flag --- .featureFlags/app_alerts.md | 11 ++++ .featureFlags/generated.tsx | 58 +++++++++++------- .featureFlags/images/app-alerts.jpg | Bin 0 -> 21443 bytes .../Sidebar/menu/hooks/useMenuStructure.tsx | 5 +- 4 files changed, 51 insertions(+), 23 deletions(-) create mode 100644 .featureFlags/app_alerts.md create mode 100644 .featureFlags/images/app-alerts.jpg diff --git a/.featureFlags/app_alerts.md b/.featureFlags/app_alerts.md new file mode 100644 index 00000000000..14ff53d2868 --- /dev/null +++ b/.featureFlags/app_alerts.md @@ -0,0 +1,11 @@ +--- +name: app_alerts +displayName: App alerts +enabled: true +payload: "default" +visible: true +--- + +![new filters](./images/app-alerts.jpg) +Experience new notifications displaying alerts for apps in the Dashboard. +Get meaningful information when Saleor detects issues with an app. \ No newline at end of file diff --git a/.featureFlags/generated.tsx b/.featureFlags/generated.tsx index ff15da4f5f5..2594c54926a 100644 --- a/.featureFlags/generated.tsx +++ b/.featureFlags/generated.tsx @@ -1,42 +1,47 @@ // @ts-nocheck -import T70991 from "./images/attributes-filters.png" -import Y99173 from "./images/collection-filters.jpg" -import I21828 from "./images/customers-filters.png" -import Z18995 from "./images/discounts-list.png" -import Z18247 from "./images/draft-orders-filters.png" -import O69452 from "./images/gift-cards-filters.png" -import P07654 from "./images/improved_refunds.png" -import V06808 from "./images/page-filters.png" -import Y05529 from "./images/product-types-filters.png" -import D04094 from "./images/staff-members-filters.png" -import Q41635 from "./images/vouchers-filters.png" +import E16085 from "./images/app-alerts.jpg" +import V99960 from "./images/attributes-filters.png" +import R39651 from "./images/collection-filters.jpg" +import A90106 from "./images/customers-filters.png" +import Z10619 from "./images/discounts-list.png" +import K70082 from "./images/draft-orders-filters.png" +import W22462 from "./images/gift-cards-filters.png" +import S38521 from "./images/improved_refunds.png" +import Y19449 from "./images/page-filters.png" +import X34044 from "./images/product-types-filters.png" +import K15194 from "./images/staff-members-filters.png" +import Q88746 from "./images/vouchers-filters.png" -const attributes_filters = () => (<>

new filters +const app_alerts = () => (<>

new filters +Experience new notifications displaying alerts for apps in the Dashboard. +Get meaningful information when Saleor detects issues with an app.

+) +const attributes_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const collection_filters = () => (<>

new filters +const collection_filters = () => (<>

new filters Experience the new look and enhanced abilities of new filtering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const customers_filters = () => (<>

new filters +const customers_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const discounts_rules = () => (<>

Discount rules

+const discounts_rules = () => (<>

Discount rules

Apply the new discounts rules to narrow your promotions audience. Set up conditions and channels that must be fulfilled to apply defined reward.

) -const draft_orders_filters = () => (<>

new filters +const draft_orders_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const gift_cards_filters = () => (<>

new filters +const gift_cards_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const improved_refunds = () => (<>

Improved refunds

+const improved_refunds = () => (<>

Improved refunds

Enable the enhanced refund feature to streamline your refund process:

  • • Choose between automatic calculations based on selected items or enter refund amounts directly for overcharges and custom adjustments.

    @@ -46,24 +51,33 @@ const improved_refunds = () => (<>

    Improved refunds<

) -const pages_filters = () => (<>

new filters +const pages_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const product_types_filters = () => (<>

new filters +const product_types_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const staff_members_filters = () => (<>

new filters +const staff_members_filters = () => (<>

new filters Experience the new look and enhanced abilities of new filtering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const vouchers_filters = () => (<>

new filters +const vouchers_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) export const AVAILABLE_FLAGS = [{ + name: "app_alerts", + displayName: "App alerts", + component: app_alerts, + visible: true, + content: { + enabled: true, + payload: "default", + } +},{ name: "attributes_filters", displayName: "Attributes filtering", component: attributes_filters, diff --git a/.featureFlags/images/app-alerts.jpg b/.featureFlags/images/app-alerts.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6f35af097b4a403ed417c45d045d99e0a49128ff GIT binary patch literal 21443 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 I2KN6q0o, + endAdornment: hasAppAlertsFeatureFlag ? : null, }); const menuItems: SidebarMenuItem[] = [ { From b1b9be9d256154a239dc37f881d6cb286c472c58 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Thu, 13 Feb 2025 13:05:13 +0100 Subject: [PATCH 07/15] cr fixes --- .featureFlags/app_alerts.md | 4 +- .featureFlags/generated.tsx | 52 +++++++++---------- .../components/AppAlerts/SidebarAppAlert.tsx | 6 +-- ...est.ts => useAppsFailedDeliveries.test.ts} | 16 +++--- ...ppsAlert.ts => useAppsFailedDeliveries.ts} | 22 ++++---- 5 files changed, 50 insertions(+), 50 deletions(-) rename src/apps/components/AppAlerts/{useAllAppsAlert.test.ts => useAppsFailedDeliveries.test.ts} (88%) rename src/apps/components/AppAlerts/{useAllAppsAlert.ts => useAppsFailedDeliveries.ts} (74%) diff --git a/.featureFlags/app_alerts.md b/.featureFlags/app_alerts.md index 14ff53d2868..1c24f71ecb5 100644 --- a/.featureFlags/app_alerts.md +++ b/.featureFlags/app_alerts.md @@ -1,9 +1,9 @@ --- name: app_alerts displayName: App alerts -enabled: true +enabled: false payload: "default" -visible: true +visible: false --- ![new filters](./images/app-alerts.jpg) diff --git a/.featureFlags/generated.tsx b/.featureFlags/generated.tsx index 2594c54926a..7b1e0af4a9e 100644 --- a/.featureFlags/generated.tsx +++ b/.featureFlags/generated.tsx @@ -1,47 +1,47 @@ // @ts-nocheck -import E16085 from "./images/app-alerts.jpg" -import V99960 from "./images/attributes-filters.png" -import R39651 from "./images/collection-filters.jpg" -import A90106 from "./images/customers-filters.png" -import Z10619 from "./images/discounts-list.png" -import K70082 from "./images/draft-orders-filters.png" -import W22462 from "./images/gift-cards-filters.png" -import S38521 from "./images/improved_refunds.png" -import Y19449 from "./images/page-filters.png" -import X34044 from "./images/product-types-filters.png" -import K15194 from "./images/staff-members-filters.png" -import Q88746 from "./images/vouchers-filters.png" +import N61234 from "./images/app-alerts.jpg" +import Z40416 from "./images/attributes-filters.png" +import W28550 from "./images/collection-filters.jpg" +import M69269 from "./images/customers-filters.png" +import O68624 from "./images/discounts-list.png" +import B24355 from "./images/draft-orders-filters.png" +import A63120 from "./images/gift-cards-filters.png" +import F60192 from "./images/improved_refunds.png" +import X93874 from "./images/page-filters.png" +import M83183 from "./images/product-types-filters.png" +import H34896 from "./images/staff-members-filters.png" +import P36365 from "./images/vouchers-filters.png" -const app_alerts = () => (<>

new filters +const app_alerts = () => (<>

new filters Experience new notifications displaying alerts for apps in the Dashboard. Get meaningful information when Saleor detects issues with an app.

) -const attributes_filters = () => (<>

new filters +const attributes_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const collection_filters = () => (<>

new filters +const collection_filters = () => (<>

new filters Experience the new look and enhanced abilities of new filtering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const customers_filters = () => (<>

new filters +const customers_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const discounts_rules = () => (<>

Discount rules

+const discounts_rules = () => (<>

Discount rules

Apply the new discounts rules to narrow your promotions audience. Set up conditions and channels that must be fulfilled to apply defined reward.

) -const draft_orders_filters = () => (<>

new filters +const draft_orders_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const gift_cards_filters = () => (<>

new filters +const gift_cards_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const improved_refunds = () => (<>

Improved refunds

+const improved_refunds = () => (<>

Improved refunds

Enable the enhanced refund feature to streamline your refund process:

  • • Choose between automatic calculations based on selected items or enter refund amounts directly for overcharges and custom adjustments.

    @@ -51,19 +51,19 @@ const improved_refunds = () => (<>

    Improved refunds<

) -const pages_filters = () => (<>

new filters +const pages_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const product_types_filters = () => (<>

new filters +const product_types_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const staff_members_filters = () => (<>

new filters +const staff_members_filters = () => (<>

new filters Experience the new look and enhanced abilities of new filtering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const vouchers_filters = () => (<>

new filters +const vouchers_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) @@ -72,9 +72,9 @@ export const AVAILABLE_FLAGS = [{ name: "app_alerts", displayName: "App alerts", component: app_alerts, - visible: true, + visible: false, content: { - enabled: true, + enabled: false, payload: "default", } },{ diff --git a/src/apps/components/AppAlerts/SidebarAppAlert.tsx b/src/apps/components/AppAlerts/SidebarAppAlert.tsx index b5eecd72f43..0c7bd0d2323 100644 --- a/src/apps/components/AppAlerts/SidebarAppAlert.tsx +++ b/src/apps/components/AppAlerts/SidebarAppAlert.tsx @@ -6,7 +6,7 @@ import React, { useState } from "react"; import { FormattedMessage } from "react-intl"; import { Link } from "react-router-dom"; -import { useAllAppsAlert } from "./useAllAppsAlert"; +import { useAppsFailedDeliveries } from "./useAppsFailedDeliveries"; const ExclamationIconComponent = () => { const [isHovered, setIsHovered] = useState(false); @@ -27,8 +27,8 @@ const ExclamationIconComponent = () => { }; export const SidebarAppAlert = () => { - const { hasFailed, hasPending } = useAllAppsAlert(); - const hasIssues = hasFailed || hasPending; + const { hasFailed, hasPendingFailed } = useAppsFailedDeliveries(); + const hasIssues = hasFailed || hasPendingFailed; if (!hasIssues) { return null; diff --git a/src/apps/components/AppAlerts/useAllAppsAlert.test.ts b/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts similarity index 88% rename from src/apps/components/AppAlerts/useAllAppsAlert.test.ts rename to src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts index 3d9384e3ab8..5ef8acf73a8 100644 --- a/src/apps/components/AppAlerts/useAllAppsAlert.test.ts +++ b/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts @@ -2,12 +2,12 @@ import { useUserPermissions } from "@dashboard/auth/hooks/useUserPermissions"; import { PermissionEnum, useAppFailedPendingWebhooksQuery } from "@dashboard/graphql"; import { renderHook } from "@testing-library/react-hooks"; -import { useAllAppsAlert } from "./useAllAppsAlert"; +import { useAppsFailedDeliveries } from "./useAppsFailedDeliveries"; jest.mock("@dashboard/auth/hooks/useUserPermissions"); jest.mock("@dashboard/graphql"); -describe("useAllAppsAlert", () => { +describe("useAppsFailedDeliveries", () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -30,7 +30,7 @@ describe("useAllAppsAlert", () => { }); // Act - const { result } = renderHook(() => useAllAppsAlert()); + const { result } = renderHook(() => useAppsFailedDeliveries()); // Assert expect(result.current).toEqual({ hasFailed: false, hasPending: false }); @@ -42,7 +42,7 @@ describe("useAllAppsAlert", () => { (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); // Act - const { result } = renderHook(() => useAllAppsAlert()); + const { result } = renderHook(() => useAppsFailedDeliveries()); expect(result.current).toEqual({ hasFailed: false, hasPending: false }); }); @@ -53,7 +53,7 @@ describe("useAllAppsAlert", () => { (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); // Act - const { result } = renderHook(() => useAllAppsAlert()); + const { result } = renderHook(() => useAppsFailedDeliveries()); // Assert expect(result.current).toEqual({ hasFailed: false, hasPending: false }); @@ -109,12 +109,12 @@ describe("useAllAppsAlert", () => { }); // Act - const { result } = renderHook(() => useAllAppsAlert()); + const { result } = renderHook(() => useAppsFailedDeliveries()); // rerender(); // Assert - expect(result.current.hasPending).toEqual(true); + expect(result.current.hasPendingFailed).toEqual(true); expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ skip: false, }); @@ -147,7 +147,7 @@ describe("useAllAppsAlert", () => { }); // Act - const { result } = renderHook(() => useAllAppsAlert()); + const { result } = renderHook(() => useAppsFailedDeliveries()); // Assert expect(result.current.hasFailed).toEqual(true); diff --git a/src/apps/components/AppAlerts/useAllAppsAlert.ts b/src/apps/components/AppAlerts/useAppsFailedDeliveries.ts similarity index 74% rename from src/apps/components/AppAlerts/useAllAppsAlert.ts rename to src/apps/components/AppAlerts/useAppsFailedDeliveries.ts index 7953ecdbefd..8acd1c550cb 100644 --- a/src/apps/components/AppAlerts/useAllAppsAlert.ts +++ b/src/apps/components/AppAlerts/useAppsFailedDeliveries.ts @@ -10,21 +10,21 @@ import { useMemo } from "react"; type Webhook = NonNullable< NonNullable["edges"][0]["node"]["webhooks"] >[0]; -interface FailedWebhooksCount { +interface AppsFailedDeliveries { hasFailed: boolean; - hasPending: boolean; + hasPendingFailed: boolean; } const requiredPermissions = [PermissionEnum.MANAGE_APPS]; -const defaultFailedWebhooksInfo: FailedWebhooksCount = { +const defaultFailedWebhooksInfo: AppsFailedDeliveries = { hasFailed: false, - hasPending: false, + hasPendingFailed: false, }; -const hasFailedCheck = (webhook: Webhook) => +const hasFailedAttemptsCheck = (webhook: Webhook) => webhook.failedDelivers && webhook.failedDelivers?.edges?.length > 0; -const hasPendingCheck = (webhook: Webhook) => { +const hasFailedAttemptsInPendingCheck = (webhook: Webhook) => { const preliminaryCheck = webhook.pendingDelivers && webhook.pendingDelivers?.edges?.length > 0; if (!preliminaryCheck) return false; @@ -36,7 +36,7 @@ const hasPendingCheck = (webhook: Webhook) => { ); }; -export const useAllAppsAlert = (): FailedWebhooksCount => { +export const useAppsFailedDeliveries = (): AppsFailedDeliveries => { const permissions = useUserPermissions(); const hasRequiredPermissions = requiredPermissions.some(permission => permissions?.map(e => e.code)?.includes(permission), @@ -52,18 +52,18 @@ export const useAllAppsAlert = (): FailedWebhooksCount => { const webhookInfo = defaultFailedWebhooksInfo; app.node.webhooks?.forEach(webhook => { - if (hasFailedCheck(webhook)) { + if (hasFailedAttemptsCheck(webhook)) { webhookInfo.hasFailed = true; } - if (hasPendingCheck(webhook)) { - webhookInfo.hasPending = true; + if (hasFailedAttemptsInPendingCheck(webhook)) { + webhookInfo.hasPendingFailed = true; } }); return { hasFailed: webhookInfo.hasFailed || acc.hasFailed, - hasPending: webhookInfo.hasPending || acc.hasPending, + hasPendingFailed: webhookInfo.hasPendingFailed || acc.hasPendingFailed, }; }, defaultFailedWebhooksInfo) ?? defaultFailedWebhooksInfo, [data], From 098aff45d3538b49690357d362e6767194c6d3a0 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Thu, 13 Feb 2025 13:09:39 +0100 Subject: [PATCH 08/15] fix test --- .../components/AppAlerts/useAppsFailedDeliveries.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts b/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts index 5ef8acf73a8..40a7bd92d8a 100644 --- a/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts +++ b/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts @@ -33,7 +33,7 @@ describe("useAppsFailedDeliveries", () => { const { result } = renderHook(() => useAppsFailedDeliveries()); // Assert - expect(result.current).toEqual({ hasFailed: false, hasPending: false }); + expect(result.current).toEqual({ hasFailed: false, hasPendingFailed: false }); }); it("should handle undefined permissions", () => { @@ -44,7 +44,7 @@ describe("useAppsFailedDeliveries", () => { // Act const { result } = renderHook(() => useAppsFailedDeliveries()); - expect(result.current).toEqual({ hasFailed: false, hasPending: false }); + expect(result.current).toEqual({ hasFailed: false, hasPendingFailed: false }); }); it("should return default counts when user has no permissions", () => { @@ -56,7 +56,7 @@ describe("useAppsFailedDeliveries", () => { const { result } = renderHook(() => useAppsFailedDeliveries()); // Assert - expect(result.current).toEqual({ hasFailed: false, hasPending: false }); + expect(result.current).toEqual({ hasFailed: false, hasPendingFailed: false }); expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ skip: true, }); From 433420b55a409a4890d506791f53e0b6c2232fcd Mon Sep 17 00:00:00 2001 From: Wojciech Date: Thu, 13 Feb 2025 13:18:14 +0100 Subject: [PATCH 09/15] filter by thirdparty --- src/apps/components/AppAlerts/queries.ts | 2 +- src/graphql/hooks.generated.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/components/AppAlerts/queries.ts b/src/apps/components/AppAlerts/queries.ts index 0f3b7b22df5..c7bb6decf06 100644 --- a/src/apps/components/AppAlerts/queries.ts +++ b/src/apps/components/AppAlerts/queries.ts @@ -2,7 +2,7 @@ import { gql } from "@apollo/client"; export const appFailedPendingWebhooks = gql` query AppFailedPendingWebhooks { - apps(first: 50) { + apps(first: 50, filter: { type: THIRDPARTY }) { edges { node { webhooks { diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 5efca3a3e17..69e03350dfe 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -3463,7 +3463,7 @@ export const WebhookDetailsFragmentDoc = gql` ${WebhookFragmentDoc}`; export const AppFailedPendingWebhooksDocument = gql` query AppFailedPendingWebhooks { - apps(first: 50) { + apps(first: 50, filter: {type: THIRDPARTY}) { edges { node { webhooks { From 379e9269b5637f29451f13c67cf10e3c61b645aa Mon Sep 17 00:00:00 2001 From: Wojciech Date: Thu, 13 Feb 2025 14:48:05 +0100 Subject: [PATCH 10/15] hook refactor --- .../components/AppAlerts/SidebarAppAlert.tsx | 7 +- src/apps/components/AppAlerts/useAppsAlert.ts | 16 ++ .../AppAlerts/useAppsFailedDeliveries.test.ts | 222 +++++++++++------- .../AppAlerts/useAppsFailedDeliveries.ts | 53 ++--- 4 files changed, 185 insertions(+), 113 deletions(-) create mode 100644 src/apps/components/AppAlerts/useAppsAlert.ts diff --git a/src/apps/components/AppAlerts/SidebarAppAlert.tsx b/src/apps/components/AppAlerts/SidebarAppAlert.tsx index 0c7bd0d2323..7975289c57c 100644 --- a/src/apps/components/AppAlerts/SidebarAppAlert.tsx +++ b/src/apps/components/AppAlerts/SidebarAppAlert.tsx @@ -6,7 +6,7 @@ import React, { useState } from "react"; import { FormattedMessage } from "react-intl"; import { Link } from "react-router-dom"; -import { useAppsFailedDeliveries } from "./useAppsFailedDeliveries"; +import { useAppsAlert } from "./useAppsAlert"; const ExclamationIconComponent = () => { const [isHovered, setIsHovered] = useState(false); @@ -27,10 +27,9 @@ const ExclamationIconComponent = () => { }; export const SidebarAppAlert = () => { - const { hasFailed, hasPendingFailed } = useAppsFailedDeliveries(); - const hasIssues = hasFailed || hasPendingFailed; + const { hasFailedAttempts } = useAppsAlert(); - if (!hasIssues) { + if (!hasFailedAttempts) { return null; } diff --git a/src/apps/components/AppAlerts/useAppsAlert.ts b/src/apps/components/AppAlerts/useAppsAlert.ts new file mode 100644 index 00000000000..5713d3f2d13 --- /dev/null +++ b/src/apps/components/AppAlerts/useAppsAlert.ts @@ -0,0 +1,16 @@ +import { useEffect } from "react"; + +import { useAppsFailedDeliveries } from "./useAppsFailedDeliveries"; + +export const useAppsAlert = () => { + const { hasFailed, fetchAppsWebhooks } = useAppsFailedDeliveries(); + + // TODO: Implement fetching at intervals + useEffect(() => { + fetchAppsWebhooks(); + }, []); + + return { + hasFailedAttempts: hasFailed, + }; +}; diff --git a/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts b/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts index 40a7bd92d8a..4768bed3c91 100644 --- a/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts +++ b/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts @@ -1,5 +1,5 @@ import { useUserPermissions } from "@dashboard/auth/hooks/useUserPermissions"; -import { PermissionEnum, useAppFailedPendingWebhooksQuery } from "@dashboard/graphql"; +import { PermissionEnum, useAppFailedPendingWebhooksLazyQuery } from "@dashboard/graphql"; import { renderHook } from "@testing-library/react-hooks"; import { useAppsFailedDeliveries } from "./useAppsFailedDeliveries"; @@ -7,6 +7,8 @@ import { useAppsFailedDeliveries } from "./useAppsFailedDeliveries"; jest.mock("@dashboard/auth/hooks/useUserPermissions"); jest.mock("@dashboard/graphql"); +const fetchingFunction = jest.fn(); + describe("useAppsFailedDeliveries", () => { beforeEach(() => { jest.clearAllMocks(); @@ -15,144 +17,204 @@ describe("useAppsFailedDeliveries", () => { it("should handle null webhook data", () => { // Arrange (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); - (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ - data: { - apps: { - edges: [ - { - node: { - webhooks: null, + (useAppFailedPendingWebhooksLazyQuery as jest.Mock).mockReturnValue([ + fetchingFunction, + { + data: { + apps: { + edges: [ + { + node: { + webhooks: null, + }, }, - }, - ], + ], + }, }, }, - }); + ]); // Act const { result } = renderHook(() => useAppsFailedDeliveries()); + result.current.fetchAppsWebhooks(); + // Assert - expect(result.current).toEqual({ hasFailed: false, hasPendingFailed: false }); + expect(result.current.hasFailed).toEqual(false); }); it("should handle undefined permissions", () => { // Arrange (useUserPermissions as jest.Mock).mockReturnValue(undefined); - (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); + (useAppFailedPendingWebhooksLazyQuery as jest.Mock).mockReturnValue([ + fetchingFunction, + { data: null }, + ]); // Act const { result } = renderHook(() => useAppsFailedDeliveries()); - expect(result.current).toEqual({ hasFailed: false, hasPendingFailed: false }); + expect(result.current.hasFailed).toEqual(false); }); it("should return default counts when user has no permissions", () => { // Arrange (useUserPermissions as jest.Mock).mockReturnValue([]); - (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ data: null }); + (useAppFailedPendingWebhooksLazyQuery as jest.Mock).mockReturnValue([ + fetchingFunction, + { data: null }, + ]); // Act const { result } = renderHook(() => useAppsFailedDeliveries()); + result.current.fetchAppsWebhooks(); + // Assert - expect(result.current).toEqual({ hasFailed: false, hasPendingFailed: false }); - expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ - skip: true, - }); + expect(fetchingFunction).not.toHaveBeenCalled(); + expect(result.current.hasFailed).toEqual(false); }); it("should check webhooks correctly for pending deliveries when user has permissions", () => { // Arrange (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); - (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ - data: { - apps: { - edges: [ - { - node: { - webhooks: [ - { - failedDelivers: { edges: [] }, - pendingDelivers: { - edges: [ - { - node: { - attempts: { - edges: [{ node: { status: "FAILED" } }], + (useAppFailedPendingWebhooksLazyQuery as jest.Mock).mockReturnValue([ + fetchingFunction, + { + data: { + apps: { + edges: [ + { + node: { + webhooks: [ + { + failedDelivers: { edges: [] }, + pendingDelivers: { + edges: [ + { + node: { + attempts: { + edges: [{ node: { status: "FAILED" } }], + }, }, }, - }, - ], + ], + }, + }, + { + failedDelivers: { edges: [] }, + pendingDelivers: null, + }, + ], + }, + }, + ], + }, + }, + }, + ]); + + // Act + const { result } = renderHook(() => useAppsFailedDeliveries()); + + result.current.fetchAppsWebhooks(); + + // Assert + expect(fetchingFunction).toHaveBeenCalled(); + expect(result.current.hasFailed).toEqual(true); + }); + + it("should check webhooks correctly when user has permissions", () => { + // Arrange + (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); + (useAppFailedPendingWebhooksLazyQuery as jest.Mock).mockReturnValue([ + fetchingFunction, + { + data: { + apps: { + edges: [ + { + node: { + webhooks: [ + { + failedDelivers: { edges: [1, 2] }, + pendingDelivers: [], }, - }, - { - failedDelivers: { edges: [] }, - pendingDelivers: { - edges: [ - { - node: { - attempts: { - edges: [{ node: { status: "FAILED" } }], + { + failedDelivers: null, + pendingDelivers: { + edges: [ + { + node: { + attempts: { + edges: [{ node: { status: "FAILED" } }], + }, }, }, - }, - ], + ], + }, }, - }, - ], + ], + }, }, - }, - ], + ], + }, }, }, - }); + ]); // Act const { result } = renderHook(() => useAppsFailedDeliveries()); - // rerender(); + result.current.fetchAppsWebhooks(); // Assert - expect(result.current.hasPendingFailed).toEqual(true); - expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ - skip: false, - }); + expect(fetchingFunction).toHaveBeenCalled(); + expect(result.current.hasFailed).toEqual(true); }); - it("should check webhooks correctly when user has permissions", () => { + it("should check webhooks correctly for both delivery fail types", () => { // Arrange (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); - (useAppFailedPendingWebhooksQuery as jest.Mock).mockReturnValue({ - data: { - apps: { - edges: [ - { - node: { - webhooks: [ - { - failedDelivers: { edges: [1, 2] }, - pendingDelivers: [], - }, - { - failedDelivers: { edges: [1] }, - pendingDelivers: [], - }, - ], + (useAppFailedPendingWebhooksLazyQuery as jest.Mock).mockReturnValue([ + fetchingFunction, + { + data: { + apps: { + edges: [ + { + node: { + webhooks: [ + { + failedDelivers: { edges: [1, 2] }, + pendingDelivers: { + edges: [ + { + node: { + attempts: { + edges: [{ node: { status: "FAILED" } }], + }, + }, + }, + ], + }, + }, + ], + }, }, - }, - ], + ], + }, }, }, - }); + ]); // Act const { result } = renderHook(() => useAppsFailedDeliveries()); + result.current.fetchAppsWebhooks(); + // Assert + expect(fetchingFunction).toHaveBeenCalled(); expect(result.current.hasFailed).toEqual(true); - expect(useAppFailedPendingWebhooksQuery).toHaveBeenCalledWith({ - skip: false, - }); }); }); diff --git a/src/apps/components/AppAlerts/useAppsFailedDeliveries.ts b/src/apps/components/AppAlerts/useAppsFailedDeliveries.ts index 8acd1c550cb..43d795a4e94 100644 --- a/src/apps/components/AppAlerts/useAppsFailedDeliveries.ts +++ b/src/apps/components/AppAlerts/useAppsFailedDeliveries.ts @@ -3,7 +3,7 @@ import { AppFailedPendingWebhooksQuery, EventDeliveryStatusEnum, PermissionEnum, - useAppFailedPendingWebhooksQuery, + useAppFailedPendingWebhooksLazyQuery, } from "@dashboard/graphql"; import { useMemo } from "react"; @@ -12,16 +12,11 @@ type Webhook = NonNullable< >[0]; interface AppsFailedDeliveries { hasFailed: boolean; - hasPendingFailed: boolean; + fetchAppsWebhooks: () => void; } const requiredPermissions = [PermissionEnum.MANAGE_APPS]; -const defaultFailedWebhooksInfo: AppsFailedDeliveries = { - hasFailed: false, - hasPendingFailed: false, -}; - const hasFailedAttemptsCheck = (webhook: Webhook) => webhook.failedDelivers && webhook.failedDelivers?.edges?.length > 0; const hasFailedAttemptsInPendingCheck = (webhook: Webhook) => { @@ -42,32 +37,32 @@ export const useAppsFailedDeliveries = (): AppsFailedDeliveries => { permissions?.map(e => e.code)?.includes(permission), ); - const { data } = useAppFailedPendingWebhooksQuery({ - skip: !hasRequiredPermissions, - }); + const [fetchAppsWebhooks, { data }] = useAppFailedPendingWebhooksLazyQuery(); - const failedWebhooksInfo = useMemo( + const hasFailed = useMemo( () => - data?.apps?.edges.reduce((acc, app) => { - const webhookInfo = defaultFailedWebhooksInfo; - - app.node.webhooks?.forEach(webhook => { - if (hasFailedAttemptsCheck(webhook)) { - webhookInfo.hasFailed = true; - } + data?.apps?.edges.some( + app => + app.node.webhooks?.some(webhook => { + if (hasFailedAttemptsCheck(webhook) || hasFailedAttemptsInPendingCheck(webhook)) { + return true; + } - if (hasFailedAttemptsInPendingCheck(webhook)) { - webhookInfo.hasPendingFailed = true; - } - }); - - return { - hasFailed: webhookInfo.hasFailed || acc.hasFailed, - hasPendingFailed: webhookInfo.hasPendingFailed || acc.hasPendingFailed, - }; - }, defaultFailedWebhooksInfo) ?? defaultFailedWebhooksInfo, + return false; + }), + false, + ) ?? false, [data], ); - return failedWebhooksInfo; + const handleFetchAppsWebhooks = () => { + if (hasRequiredPermissions) { + fetchAppsWebhooks(); + } + }; + + return { + hasFailed, + fetchAppsWebhooks: handleFetchAppsWebhooks, + }; }; From 651fab7b849797c5872b80507d5d8bdc271f0950 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Thu, 13 Feb 2025 14:56:57 +0100 Subject: [PATCH 11/15] add missing test --- .../AppAlerts/useAppsFailedDeliveries.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts b/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts index 4768bed3c91..955c177fe90 100644 --- a/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts +++ b/src/apps/components/AppAlerts/useAppsFailedDeliveries.test.ts @@ -75,6 +75,47 @@ describe("useAppsFailedDeliveries", () => { expect(result.current.hasFailed).toEqual(false); }); + it("should not flag as fails if there are no failed webhooks", () => { + // Arrange + (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); + (useAppFailedPendingWebhooksLazyQuery as jest.Mock).mockReturnValue([ + fetchingFunction, + { + data: { + apps: { + edges: [ + { + node: { + webhooks: [ + { + failedDelivers: { edges: [] }, + pendingDelivers: { + edges: [], + }, + }, + { + failedDelivers: { edges: [] }, + pendingDelivers: { edges: [] }, + }, + ], + }, + }, + ], + }, + }, + }, + ]); + + // Act + const { result } = renderHook(() => useAppsFailedDeliveries()); + + result.current.fetchAppsWebhooks(); + + // Assert + expect(fetchingFunction).toHaveBeenCalled(); + expect(result.current.hasFailed).toEqual(false); + }); + it("should check webhooks correctly for pending deliveries when user has permissions", () => { // Arrange (useUserPermissions as jest.Mock).mockReturnValue([{ code: PermissionEnum.MANAGE_APPS }]); From 6f272f3f3f280b5d4eed11b670865586890eb62e Mon Sep 17 00:00:00 2001 From: Wojciech Date: Mon, 17 Feb 2025 11:02:37 +0100 Subject: [PATCH 12/15] generate flag --- .featureFlags/generated.tsx | 48 ++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/.featureFlags/generated.tsx b/.featureFlags/generated.tsx index 7b1e0af4a9e..234ed066b08 100644 --- a/.featureFlags/generated.tsx +++ b/.featureFlags/generated.tsx @@ -1,47 +1,47 @@ // @ts-nocheck -import N61234 from "./images/app-alerts.jpg" -import Z40416 from "./images/attributes-filters.png" -import W28550 from "./images/collection-filters.jpg" -import M69269 from "./images/customers-filters.png" -import O68624 from "./images/discounts-list.png" -import B24355 from "./images/draft-orders-filters.png" -import A63120 from "./images/gift-cards-filters.png" -import F60192 from "./images/improved_refunds.png" -import X93874 from "./images/page-filters.png" -import M83183 from "./images/product-types-filters.png" -import H34896 from "./images/staff-members-filters.png" -import P36365 from "./images/vouchers-filters.png" +import V12790 from "./images/app-alerts.jpg" +import T17950 from "./images/attributes-filters.png" +import G58190 from "./images/collection-filters.jpg" +import G66204 from "./images/customers-filters.png" +import T45169 from "./images/discounts-list.png" +import X76306 from "./images/draft-orders-filters.png" +import N44839 from "./images/gift-cards-filters.png" +import Q62037 from "./images/improved_refunds.png" +import K96222 from "./images/page-filters.png" +import B98101 from "./images/product-types-filters.png" +import X94293 from "./images/staff-members-filters.png" +import N20646 from "./images/vouchers-filters.png" -const app_alerts = () => (<>

new filters +const app_alerts = () => (<>

new filters Experience new notifications displaying alerts for apps in the Dashboard. Get meaningful information when Saleor detects issues with an app.

) -const attributes_filters = () => (<>

new filters +const attributes_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const collection_filters = () => (<>

new filters +const collection_filters = () => (<>

new filters Experience the new look and enhanced abilities of new filtering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const customers_filters = () => (<>

new filters +const customers_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const discounts_rules = () => (<>

Discount rules

+const discounts_rules = () => (<>

Discount rules

Apply the new discounts rules to narrow your promotions audience. Set up conditions and channels that must be fulfilled to apply defined reward.

) -const draft_orders_filters = () => (<>

new filters +const draft_orders_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const gift_cards_filters = () => (<>

new filters +const gift_cards_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const improved_refunds = () => (<>

Improved refunds

+const improved_refunds = () => (<>

Improved refunds

Enable the enhanced refund feature to streamline your refund process:

  • • Choose between automatic calculations based on selected items or enter refund amounts directly for overcharges and custom adjustments.

    @@ -51,19 +51,19 @@ const improved_refunds = () => (<>

    Improved refunds<

) -const pages_filters = () => (<>

new filters +const pages_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const product_types_filters = () => (<>

new filters +const product_types_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const staff_members_filters = () => (<>

new filters +const staff_members_filters = () => (<>

new filters Experience the new look and enhanced abilities of new filtering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const vouchers_filters = () => (<>

new filters +const vouchers_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) From be4f48b3f1b47d705f1cdec64ebcd9b172918f61 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Mon, 17 Feb 2025 11:03:25 +0100 Subject: [PATCH 13/15] generate flag --- .featureFlags/generated.tsx | 48 ++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/.featureFlags/generated.tsx b/.featureFlags/generated.tsx index 234ed066b08..3f23082b650 100644 --- a/.featureFlags/generated.tsx +++ b/.featureFlags/generated.tsx @@ -1,47 +1,47 @@ // @ts-nocheck -import V12790 from "./images/app-alerts.jpg" -import T17950 from "./images/attributes-filters.png" -import G58190 from "./images/collection-filters.jpg" -import G66204 from "./images/customers-filters.png" -import T45169 from "./images/discounts-list.png" -import X76306 from "./images/draft-orders-filters.png" -import N44839 from "./images/gift-cards-filters.png" -import Q62037 from "./images/improved_refunds.png" -import K96222 from "./images/page-filters.png" -import B98101 from "./images/product-types-filters.png" -import X94293 from "./images/staff-members-filters.png" -import N20646 from "./images/vouchers-filters.png" +import X06116 from "./images/app-alerts.jpg" +import E39363 from "./images/attributes-filters.png" +import X31313 from "./images/collection-filters.jpg" +import N52326 from "./images/customers-filters.png" +import C57589 from "./images/discounts-list.png" +import V99682 from "./images/draft-orders-filters.png" +import Y90779 from "./images/gift-cards-filters.png" +import Z62416 from "./images/improved_refunds.png" +import P29358 from "./images/page-filters.png" +import T77822 from "./images/product-types-filters.png" +import H41256 from "./images/staff-members-filters.png" +import I18701 from "./images/vouchers-filters.png" -const app_alerts = () => (<>

new filters +const app_alerts = () => (<>

new filters Experience new notifications displaying alerts for apps in the Dashboard. Get meaningful information when Saleor detects issues with an app.

) -const attributes_filters = () => (<>

new filters +const attributes_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const collection_filters = () => (<>

new filters +const collection_filters = () => (<>

new filters Experience the new look and enhanced abilities of new filtering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const customers_filters = () => (<>

new filters +const customers_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const discounts_rules = () => (<>

Discount rules

+const discounts_rules = () => (<>

Discount rules

Apply the new discounts rules to narrow your promotions audience. Set up conditions and channels that must be fulfilled to apply defined reward.

) -const draft_orders_filters = () => (<>

new filters +const draft_orders_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const gift_cards_filters = () => (<>

new filters +const gift_cards_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const improved_refunds = () => (<>

Improved refunds

+const improved_refunds = () => (<>

Improved refunds

Enable the enhanced refund feature to streamline your refund process:

  • • Choose between automatic calculations based on selected items or enter refund amounts directly for overcharges and custom adjustments.

    @@ -51,19 +51,19 @@ const improved_refunds = () => (<>

    Improved refunds<

) -const pages_filters = () => (<>

new filters +const pages_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const product_types_filters = () => (<>

new filters +const product_types_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const staff_members_filters = () => (<>

new filters +const staff_members_filters = () => (<>

new filters Experience the new look and enhanced abilities of new filtering mechanism. Easily combine any criteria you want, and quickly browse their values.

) -const vouchers_filters = () => (<>

new filters +const vouchers_filters = () => (<>

new filters Experience the new look and enhanced abilities of new fitering mechanism. Easily combine any criteria you want, and quickly browse their values.

) From 4db334c4ccccdb2e1070e9cdec7a4e397b6a0bd8 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Wed, 19 Feb 2025 09:50:44 +0100 Subject: [PATCH 14/15] extract colors --- src/apps/components/AppAlerts/SidebarAppAlert.tsx | 5 ++--- src/colors.ts | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 src/colors.ts diff --git a/src/apps/components/AppAlerts/SidebarAppAlert.tsx b/src/apps/components/AppAlerts/SidebarAppAlert.tsx index 7975289c57c..157e1d2366c 100644 --- a/src/apps/components/AppAlerts/SidebarAppAlert.tsx +++ b/src/apps/components/AppAlerts/SidebarAppAlert.tsx @@ -1,4 +1,5 @@ import { AppSections } from "@dashboard/apps/urls"; +import { WARNING_ICON_COLOR, WARNING_ICON_COLOR_LIGHTER } from "@dashboard/colors"; import { ExclamationIcon } from "@dashboard/icons/ExclamationIcon"; import { ExclamationIconFilled } from "@dashboard/icons/ExclamationIconFilled"; import { Box, Text, Tooltip } from "@saleor/macaw-ui-next"; @@ -10,12 +11,10 @@ import { useAppsAlert } from "./useAppsAlert"; const ExclamationIconComponent = () => { const [isHovered, setIsHovered] = useState(false); - const colorLighter = "#FFD87E"; - const colorDefault = "#FFB84E"; return ( setIsHovered(true)} diff --git a/src/colors.ts b/src/colors.ts new file mode 100644 index 00000000000..0bb0bb1cb11 --- /dev/null +++ b/src/colors.ts @@ -0,0 +1,2 @@ +export const WARNING_ICON_COLOR = "#FFB84E"; +export const WARNING_ICON_COLOR_LIGHTER = "#FFD87E"; From 420dd0dec56d1d383228446cb4d76231d44bfa82 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Wed, 19 Feb 2025 09:55:56 +0100 Subject: [PATCH 15/15] remove unused colors --- src/misc.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/misc.ts b/src/misc.ts index ab80d853d6c..cc79308510c 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -567,9 +567,6 @@ export const getByUnmatchingId = (idToCompare: string) => (obj: { id: string }) export const findById = (id: string, list?: T[]) => list?.find(getById(id)); -export const COLOR_WARNING = "#FBE5AC"; -export const COLOR_WARNING_DARK = "#3E2F0A"; - export type PillStatusType = "error" | "warning" | "info" | "success" | "generic"; export const getStatusColor = ({