Skip to content

Commit

Permalink
Merge branch 'main' into allow-skipping-changeset
Browse files Browse the repository at this point in the history
  • Loading branch information
poulch authored Feb 27, 2025
2 parents 19031f8 + c2f5824 commit 3894020
Show file tree
Hide file tree
Showing 6 changed files with 269 additions and 47 deletions.
5 changes: 5 additions & 0 deletions .changeset/thick-ravens-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"saleor-dashboard": patch
---

App delivery events are now fetched at a 5 minute interval. This means this heavy query is now used sparingly.
15 changes: 11 additions & 4 deletions src/apps/components/AppAlerts/useAppsAlert.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { useHasManagedAppsPermission } from "@dashboard/hooks/useHasManagedAppsPermission";
import { useIntervalActionWithState } from "@dashboard/hooks/useIntervalActionWithState";
import { useEffect } from "react";

import { useAppsFailedDeliveries } from "./useAppsFailedDeliveries";
import { useSidebarDotState } from "./useSidebarDotState";

const DELIVERIES_FETCHING_INTERVAL = 5 * 60 * 1000; // 5 minutes

export const useAppsAlert = () => {
const { hasManagedAppsPermission } = useHasManagedAppsPermission();
const { hasNewFailedAttempts, handleFailedAttempt } = useSidebarDotState();
const { lastFailedWebhookDate, fetchAppsWebhooks } = useAppsFailedDeliveries();

// TODO: Implement fetching at intervals
useEffect(() => {
fetchAppsWebhooks();
}, []);
useIntervalActionWithState({
action: fetchAppsWebhooks,
interval: DELIVERIES_FETCHING_INTERVAL,
key: "webhook_deliveries_last_fetched",
skip: !hasManagedAppsPermission,
});

useEffect(() => {
if (lastFailedWebhookDate) {
Expand Down
40 changes: 18 additions & 22 deletions src/apps/components/AppAlerts/useAppsFailedDeliveries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useAppFailedPendingWebhooksLazyQuery } from "@dashboard/graphql";
import { useHasManagedAppsPermission } from "@dashboard/hooks/useHasManagedAppsPermission";
import { Moment } from "moment";
import { useMemo } from "react";

Expand All @@ -12,36 +11,33 @@ interface AppsFailedDeliveries {
}

export const useAppsFailedDeliveries = (): AppsFailedDeliveries => {
const { hasManagedAppsPermission } = useHasManagedAppsPermission();

const [fetchAppsWebhooks, { data }] = useAppFailedPendingWebhooksLazyQuery();
const [fetchAppsWebhooks, { data }] = useAppFailedPendingWebhooksLazyQuery({
fetchPolicy: "no-cache",
});

const lastFailedWebhookDate = useMemo(
() =>
data?.apps?.edges.reduce(
(acc, app) => {
const latestFailedAttempt = getLatestFailedAttemptFromWebhooks(app.node.webhooks ?? []);

if (!latestFailedAttempt) {
return acc;
}

if (!acc) {
return latestFailedAttempt.createdAt;
}

return latestFailedAttempt.createdAt > acc ? latestFailedAttempt : acc;
},
null as Moment | null,
) ?? null,
data?.apps?.edges.reduce((acc, app) => {
const latestFailedAttempt = getLatestFailedAttemptFromWebhooks(app.node.webhooks ?? []);

if (!latestFailedAttempt) {
return acc;
}

if (!acc) {
return latestFailedAttempt.createdAt;
}

return latestFailedAttempt.createdAt > acc ? latestFailedAttempt : acc;
}, null) ?? null,
[data?.apps?.edges],
);

const handleFetchAppsWebhooks = () => {
// TODO: checking if webhooks should be fetched will be extracted out of this hook in a separate ticket
// Permissions are checked outside of this hook
fetchAppsWebhooks({
variables: {
canFetchAppEvents: hasManagedAppsPermission,
canFetchAppEvents: true,
},
});
};
Expand Down
21 changes: 0 additions & 21 deletions src/hooks/useInterval.ts

This file was deleted.

181 changes: 181 additions & 0 deletions src/hooks/useIntervalActionWithState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import useLocalStorage from "@dashboard/hooks/useLocalStorage";
import { renderHook } from "@testing-library/react-hooks";
import { useState } from "react";
import { act } from "react-dom/test-utils";

import { useIntervalActionWithState } from "./useIntervalActionWithState";

jest.mock("@dashboard/hooks/useLocalStorage", () => ({
__esModule: true,
default: jest.fn(() => {
const [value, setValue] = useState(0);

return [value, setValue];
}),
}));

const TEST_KEY = "test-key";

describe("useIntervalActionWithState", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});

afterEach(() => {
jest.useRealTimers();
});

it("should execute action immediately if interval has passed", () => {
// Arrange
const action = jest.fn();

// Act
renderHook(() =>
useIntervalActionWithState({
action,
interval: 1000,
key: TEST_KEY,
}),
);

// Assert
expect(action).toHaveBeenCalledTimes(1);
});

it("should execute action after interval", () => {
// Arrange
const action = jest.fn();

// Act
renderHook(() =>
useIntervalActionWithState({
action,
interval: 1000,
key: TEST_KEY,
}),
);

act(() => {
jest.advanceTimersByTime(1000);
});

// Assert
expect(action).toHaveBeenCalledTimes(2); // Once on mount, once after interval
});

it("should clear timeout on unmount", () => {
// Arrange
const action = jest.fn();

// Act
const { unmount } = renderHook(() =>
useIntervalActionWithState({
action,
interval: 1000,
key: TEST_KEY,
}),
);

unmount();

act(() => {
jest.advanceTimersByTime(1000);
});

// Assert
expect(action).toHaveBeenCalledTimes(1); // Only initial call
});

it("should handle multiple intervals", () => {
// Arrange
const action = jest.fn();

// Act
renderHook(() =>
useIntervalActionWithState({
action,
interval: 1000,
key: TEST_KEY,
}),
);

act(() => {
jest.advanceTimersByTime(2500);
});

// Assert
expect(action).toHaveBeenCalledTimes(3); // Initial + 2 intervals
});

it("should handle component rerenders", () => {
// Arrange
const action = jest.fn();

// Act
const { rerender } = renderHook(() =>
useIntervalActionWithState({
action,
interval: 1000,
key: TEST_KEY,
}),
);

rerender();

act(() => {
jest.advanceTimersByTime(1000);
});

// Assert
expect(action).toHaveBeenCalledTimes(2);
});

it("should calculate correct delay based on last invocation", () => {
// Arrange
const action = jest.fn();
const mockTime = new Date().getTime();

(useLocalStorage as jest.Mock).mockReturnValue([mockTime - 500, jest.fn()]);

// Act
renderHook(() =>
useIntervalActionWithState({
action,
interval: 1000,
key: TEST_KEY,
}),
);

expect(action).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(500);
});

// Assert
expect(action).toHaveBeenCalledTimes(1);
});

it("should skip execution if skip is true", () => {
// Arrange
const action = jest.fn();

// Act
renderHook(() =>
useIntervalActionWithState({
action,
interval: 1000,
key: TEST_KEY,
skip: true,
}),
);

act(() => {
jest.advanceTimersByTime(1000);
});

// Assert
expect(action).not.toHaveBeenCalled();
});
});
54 changes: 54 additions & 0 deletions src/hooks/useIntervalActionWithState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import useLocalStorage from "@dashboard/hooks/useLocalStorage";
import { useEffect, useRef } from "react";

type Timeout = ReturnType<typeof setTimeout>;

interface UseIntervalActionWithState {
action: () => void;
key: string;
interval?: number;
skip?: boolean;
}

export const useIntervalActionWithState = ({
action,
key,
interval = 5_000,
skip = false,
}: UseIntervalActionWithState) => {
const savedAction = useRef(action);
const timeout = useRef<Timeout | null>(null);
const [lastInvocation, setLastInvocation] = useLocalStorage(key, new Date().getTime());

useEffect(() => {
const cleanup = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};

if (skip) {
return cleanup;
}

const timeNow = new Date().getTime();
const timePassed = timeNow - lastInvocation;
const nextDelay = Math.max(0, interval - timePassed);

const hasPassedInterval = timePassed >= interval;

const action = () => {
savedAction.current();
setLastInvocation(new Date().getTime());
};

if (hasPassedInterval) {
action();
timeout.current = setTimeout(() => action(), interval);
} else {
timeout.current = setTimeout(() => action(), nextDelay);
}

return cleanup;
}, [lastInvocation, interval, key, setLastInvocation, skip]);
};

0 comments on commit 3894020

Please sign in to comment.