Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions apps/admin-x-framework/src/hooks/use-tinybird-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ export interface UseTinybirdQueryOptions {

// Wrapper around Tinybird's useQuery hook that handles the token loading state
export const useTinybirdQuery = (options: UseTinybirdQueryOptions) => {
const tokenQuery = useTinybirdToken();
const {statsConfig, endpoint, params, enabled = true} = options;

const shouldQuery = enabled && statsConfig && endpoint;
const endpointUrl = shouldQuery ? getStatEndpointUrl(statsConfig, endpoint) : undefined;
const shouldQuery = Boolean(enabled && statsConfig && endpoint);
const tokenQuery = useTinybirdToken({enabled: shouldQuery});
const endpointUrl = shouldQuery && statsConfig ? getStatEndpointUrl(statsConfig, endpoint) : undefined;

// Set the endpoint to undefined if:
// - Token is not loaded (prevents 403 errors)
Expand All @@ -25,14 +25,14 @@ export const useTinybirdQuery = (options: UseTinybirdQueryOptions) => {
// - No endpoint specified
const {data, meta, loading, error} = useQuery({
endpoint: (!tokenQuery.isLoading && tokenQuery.token && shouldQuery) ? endpointUrl : undefined,
token: tokenQuery.token,
token: shouldQuery ? tokenQuery.token : undefined,
params: params
});

return {
data,
meta,
loading: tokenQuery.isLoading || loading,
error: error ?? tokenQuery.error
data: shouldQuery ? data : null,
meta: shouldQuery ? meta : null,
loading: shouldQuery ? tokenQuery.isLoading || loading : false,
error: shouldQuery ? error ?? tokenQuery.error : null
};
};
49 changes: 21 additions & 28 deletions apps/admin-x-framework/test/unit/hooks/use-active-visitors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import {useTinybirdToken} from '../../../src/hooks/use-tinybird-token';
const mockUseQuery = vi.mocked(useQuery);
const mockGetStatEndpointUrl = vi.mocked(getStatEndpointUrl);
const mockUseTinybirdToken = vi.mocked(useTinybirdToken);
const statsConfig = {
id: 'test-site-id',
endpoint: 'https://api.test.com',
token: 'test-token'
};

describe('useActiveVisitors', () => {
let queryClient: QueryClient;
Expand Down Expand Up @@ -66,7 +71,7 @@ describe('useActiveVisitors', () => {
});

it('returns initial state when enabled is true', () => {
const {result} = renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
const {result} = renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});

expect(result.current).toEqual({
activeVisitors: 0,
Expand Down Expand Up @@ -97,7 +102,7 @@ describe('useActiveVisitors', () => {
refresh: vi.fn()
});

const {result} = renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
const {result} = renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});

// Should show loading on initial load when no lastKnownCount exists
expect(result.current.isLoading).toBe(true);
Expand All @@ -117,7 +122,7 @@ describe('useActiveVisitors', () => {
refresh: vi.fn()
});

const {result, rerender} = renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
const {result, rerender} = renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});
expect(result.current.activeVisitors).toBe(25);

// Second render with loading but data should not show loading
Expand Down Expand Up @@ -150,7 +155,7 @@ describe('useActiveVisitors', () => {
refresh: vi.fn()
});

const {result} = renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
const {result} = renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});

expect(result.current.activeVisitors).toBe(42);
expect(result.current.isLoading).toBe(false);
Expand All @@ -169,37 +174,31 @@ describe('useActiveVisitors', () => {
refresh: vi.fn()
});

const {result} = renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
const {result} = renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});

expect(result.current.error).toBe(mockError);
});

it('calls getStatEndpointUrl with correct parameters and uses tinybirdToken', () => {
const statsConfig = {
id: 'test-site-id',
endpoint: 'https://api.test.com',
token: 'test-token'
};

renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});

expect(mockGetStatEndpointUrl).toHaveBeenCalledWith(statsConfig, 'api_active_visitors');
expect(mockUseTinybirdToken).toHaveBeenCalled();
});

it('calls useTinybirdQuery with undefined endpoint when no statsConfig and uses tinybirdToken', () => {
it('calls useTinybirdQuery with undefined endpoint and token when no statsConfig', () => {
renderHook(() => useActiveVisitors({enabled: true}), {wrapper});

expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: undefined,
token: 'mock-token',
token: undefined,
params: expect.objectContaining({
site_uuid: ''
})
})
);
expect(mockUseTinybirdToken).toHaveBeenCalled();
expect(mockUseTinybirdToken).toHaveBeenCalledWith({enabled: false});
});

it('sets up 60-second interval when enabled', () => {
Expand Down Expand Up @@ -273,12 +272,6 @@ describe('useActiveVisitors', () => {
});

it('uses statsConfig for site_uuid', () => {
const statsConfig = {
id: 'test-site-id',
endpoint: 'https://api.test.com',
token: 'test-token'
};

renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});

expect(mockUseQuery).toHaveBeenCalledWith(
Expand Down Expand Up @@ -315,7 +308,7 @@ describe('useActiveVisitors', () => {
refresh: vi.fn()
});

const {result, rerender} = renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
const {result, rerender} = renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});
expect(result.current.activeVisitors).toBe(25);

// Simulate refresh with loading state but no new data
Expand Down Expand Up @@ -395,7 +388,7 @@ describe('useActiveVisitors', () => {
refresh: vi.fn()
});

const {result, rerender} = renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
const {result, rerender} = renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});
expect(result.current.activeVisitors).toBe(0);

// Provide valid data
Expand Down Expand Up @@ -439,7 +432,7 @@ describe('useActiveVisitors', () => {
};

const {rerender} = renderHook(
({statsConfig}) => useActiveVisitors({statsConfig, enabled: true}),
({statsConfig: currentStatsConfig}) => useActiveVisitors({statsConfig: currentStatsConfig, enabled: true}),
{initialProps: {statsConfig: initialStatsConfig}, wrapper}
);

Expand Down Expand Up @@ -473,7 +466,7 @@ describe('useActiveVisitors', () => {
});

const {result, rerender} = renderHook(
({enabled}) => useActiveVisitors({enabled}),
({enabled}) => useActiveVisitors({statsConfig, enabled}),
{initialProps: {enabled: true}, wrapper}
);

Expand Down Expand Up @@ -539,7 +532,7 @@ describe('useActiveVisitors', () => {
mockUseQuery.mockClear();

// Render the hook
renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});

// EXPECTED: useQuery should be called with undefined endpoint when token is loading
// This prevents HTTP requests by disabling the SWR query entirely
Expand All @@ -564,7 +557,7 @@ describe('useActiveVisitors', () => {
mockUseQuery.mockClear();

// Render the hook
renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});

// Should call useQuery with the valid token
expect(mockUseQuery).toHaveBeenCalledWith(
Expand All @@ -584,7 +577,7 @@ describe('useActiveVisitors', () => {
});

// Render the hook
const {rerender} = renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
const {rerender} = renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});

// Verify first call with undefined token (due to tokenLoading: true)
expect(mockUseQuery).toHaveBeenLastCalledWith(
Expand Down Expand Up @@ -636,7 +629,7 @@ describe('useActiveVisitors', () => {
refresh: vi.fn()
});

const {result} = renderHook(() => useActiveVisitors({enabled: true}), {wrapper});
const {result} = renderHook(() => useActiveVisitors({statsConfig, enabled: true}), {wrapper});

// Should show loading because token is loading and no lastKnownCount
expect(result.current.isLoading).toBe(true);
Expand Down
52 changes: 52 additions & 0 deletions apps/admin-x-framework/test/unit/hooks/use-tinybird-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,58 @@ describe('useTinybirdQuery', () => {
}));
});

it('should not fetch a token or query Tinybird when disabled', () => {
const mockError = new Error('Token error');
mockUseTinybirdToken.mockReturnValue({
token: 'mock-token',
isLoading: true,
error: mockError,
refetch: vi.fn()
});
mockUseQuery.mockReturnValue({
data: [{visits: 1}],
loading: true,
error: 'Query error',
meta: [{name: 'visits'}],
statistics: null,
endpoint: 'https://api.example.com/test',
token: 'mock-token',
refresh: vi.fn()
});

const {result} = renderHook(() => useTinybirdQuery({
statsConfig: {id: '123'},
endpoint: 'test',
params: {},
enabled: false
}), {wrapper});

expect(mockUseTinybirdToken).toHaveBeenCalledWith({enabled: false});
expect(mockGetStatEndpointUrl).not.toHaveBeenCalled();
expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({
endpoint: undefined,
token: undefined
}));
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
expect(result.current.data).toBe(null);
expect(result.current.meta).toBe(null);
});

it('should not fetch a token or query Tinybird without statsConfig', () => {
renderHook(() => useTinybirdQuery({
endpoint: 'test',
params: {}
}), {wrapper});

expect(mockUseTinybirdToken).toHaveBeenCalledWith({enabled: false});
expect(mockGetStatEndpointUrl).not.toHaveBeenCalled();
expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({
endpoint: undefined,
token: undefined
}));
});

it('should call useQuery with the correct token', () => {
mockUseTinybirdToken.mockReturnValue({
token: 'mock-token',
Expand Down
9 changes: 6 additions & 3 deletions apps/posts/src/providers/post-analytics-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {ReactNode, createContext, useContext, useState} from 'react';
import {STATS_RANGES} from '@src/utils/constants';
import {Setting, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings';
import {StatsConfig, useTinybirdToken} from '@tryghost/admin-x-framework';
import {useAppContext} from '@src/providers/posts-app-context';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useParams} from '@tryghost/admin-x-framework';

Expand Down Expand Up @@ -62,6 +63,7 @@ export const useGlobalData = () => {
};

const PostAnalyticsProvider = ({children}: { children: ReactNode }) => {
const {appSettings} = useAppContext();
const {postId} = useParams();

// Validate that postId exists - the app cannot function without it
Expand All @@ -76,7 +78,8 @@ const PostAnalyticsProvider = ({children}: { children: ReactNode }) => {

// Only fetch Tinybird token if stats config is present
const hasStatsConfig = Boolean(config.data?.config?.stats);
const tinybirdTokenQuery = useTinybirdToken({enabled: hasStatsConfig});
const shouldLoadTinybirdToken = hasStatsConfig && appSettings?.analytics?.webAnalytics === true;
const tinybirdTokenQuery = useTinybirdToken({enabled: shouldLoadTinybirdToken});

// Fetch post data with all required includes
const {data: {posts: [post]} = {posts: []}, isLoading: isPostLoading} = useBrowsePosts({
Expand All @@ -89,12 +92,12 @@ const PostAnalyticsProvider = ({children}: { children: ReactNode }) => {
// Check for errors in the ghost requests
const ghostRequests = [config, site, settings];
const ghostError = ghostRequests.map(request => request.error).find(Boolean);
const tinybirdError = hasStatsConfig ? tinybirdTokenQuery.error : null;
const tinybirdError = shouldLoadTinybirdToken ? tinybirdTokenQuery.error : null;
const error = ghostError || tinybirdError;

// Check loading states
const isGhostLoading = ghostRequests.some(request => request.isLoading);
const isTinybirdLoading = hasStatsConfig ? tinybirdTokenQuery.isLoading : false;
const isTinybirdLoading = shouldLoadTinybirdToken ? tinybirdTokenQuery.isLoading : false;
const isLoading = isGhostLoading || isTinybirdLoading;

if (error) {
Expand Down
17 changes: 10 additions & 7 deletions apps/posts/src/views/PostAnalytics/Overview/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const Overview: React.FC = () => {
const {totals, isLoading: isTotalsLoading, currencySymbol} = usePostReferrers(postId);
const {appSettings} = useAppContext();
const {emailTrackClicks: emailTrackClicksEnabled, emailTrackOpens: emailTrackOpensEnabled} = appSettings?.analytics || {};
const webAnalyticsEnabled = appSettings?.analytics?.webAnalytics === true;

// Calculate chart range based on days between today and post publication date
const chartRange = useMemo(() => {
Expand Down Expand Up @@ -57,8 +58,9 @@ const Overview: React.FC = () => {

const {data: chartData, loading: chartLoading} = useTinybirdQuery({
endpoint: 'api_kpis',
statsConfig: statsConfig || {id: ''},
params: params
statsConfig,
params: params,
enabled: webAnalyticsEnabled
});

// Calculate total visitors as a number for WebOverview component
Expand Down Expand Up @@ -87,25 +89,26 @@ const Overview: React.FC = () => {
// Get sources data
const {data: sourcesData, loading: isSourcesLoading} = useTinybirdQuery({
endpoint: 'api_top_sources',
statsConfig: statsConfig || {id: ''},
params: params
statsConfig,
params: params,
enabled: webAnalyticsEnabled
});

const kpiIsLoading = isConfigLoading || isTotalsLoading || isPostLoading || chartLoading;
const chartIsLoading = isPostLoading || isConfigLoading || chartLoading;

// Use the utility function from admin-x-framework
const showNewsletterSection = hasBeenEmailed(post as Post) && emailTrackOpensEnabled && emailTrackClicksEnabled;
const showWebSection = !post?.email_only && appSettings?.analytics.webAnalytics;
const showWebSection = !post?.email_only && webAnalyticsEnabled;
const showGrowthSection = appSettings?.analytics.membersTrackSources;

// Redirect to Growth tab if this is a published-only post with web analytics disabled
// Only redirect if Growth section is available
useEffect(() => {
if (!isPostLoading && post && isPublishedOnly(post as Post) && !appSettings?.analytics.webAnalytics && showGrowthSection) {
if (!isPostLoading && post && isPublishedOnly(post as Post) && !webAnalyticsEnabled && showGrowthSection) {
navigate(`/posts/analytics/${postId}/growth`, {replace: true});
}
}, [isPostLoading, post, appSettings?.analytics.webAnalytics, navigate, postId, showGrowthSection]);
}, [isPostLoading, post, webAnalyticsEnabled, navigate, postId, showGrowthSection]);

// First we have to wait for the post to be loaded to determine what sections (web, newsletter etc.) should be displayed
if (isPostLoading) {
Expand Down
Loading
Loading