feat(frontend): dedupe /public/settings via shared usePublicSettings hook (#325)
Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page load — 4 from raw-fetch consumers + 3 from React Query consumers using inconsistent queryKeys. Captured live in Chrome DevTools. Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts. Adds: - frontend/src/hooks/usePublicSettings.ts — single React Query hook, 60s staleTime, queryKey ['public-settings']. Vitest with mocked api proves multi-mount dedup. - Extended PublicSettings interface with seo_meta_* fields used by RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader. Migrates 19 call sites across 4 risk-ordered rounds: - Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with refetchInterval to preserve maintenance polling), MaintenanceWrapper (drops the now-redundant per-route ping; axios interceptor already handles 503), AdminHeader. - Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage, AdminLoginPage, MaintenanceMode. - Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock, ReCaptcha, useWatermarkSettings (rips out raw fetch + local state), LegalPage. - Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload, CreateEventPage. EventDetailsPage Round D ships in the follow-up commit that adds presigned-download UI on the same page. App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to later commits — both files mix #325 changes with backend feature work.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { usePublicSettings } from '../usePublicSettings';
|
||||
import { publicSettingsService } from '../../services/publicSettings.service';
|
||||
|
||||
vi.mock('../../services/publicSettings.service', () => ({
|
||||
publicSettingsService: {
|
||||
getPublicSettings: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const getPublicSettingsMock = vi.mocked(publicSettingsService.getPublicSettings);
|
||||
|
||||
function makeWrapper() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||
});
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { client, Wrapper };
|
||||
}
|
||||
|
||||
describe('usePublicSettings', () => {
|
||||
beforeEach(() => {
|
||||
getPublicSettingsMock.mockReset();
|
||||
});
|
||||
|
||||
it('returns settings from the public settings service', async () => {
|
||||
getPublicSettingsMock.mockResolvedValue({
|
||||
branding_company_name: 'PicPeak Test',
|
||||
maintenance_mode: false,
|
||||
} as Awaited<ReturnType<typeof publicSettingsService.getPublicSettings>>);
|
||||
|
||||
const { Wrapper } = makeWrapper();
|
||||
const { result } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.branding_company_name).toBe('PicPeak Test');
|
||||
expect(getPublicSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dedupes parallel callers in the same QueryClient', async () => {
|
||||
getPublicSettingsMock.mockResolvedValue({
|
||||
branding_company_name: 'PicPeak Test',
|
||||
} as Awaited<ReturnType<typeof publicSettingsService.getPublicSettings>>);
|
||||
|
||||
const { Wrapper } = makeWrapper();
|
||||
const { result: first } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
|
||||
const { result: second } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
|
||||
const { result: third } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(first.current.isSuccess).toBe(true);
|
||||
expect(second.current.isSuccess).toBe(true);
|
||||
expect(third.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
// Single network call regardless of how many components mount the hook.
|
||||
expect(getPublicSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,4 +2,5 @@ export * from './useSessionTimeout';
|
||||
export * from './useOnClickOutside';
|
||||
export * from './useLocalizedDate';
|
||||
export * from './useLocalizedTimeAgo';
|
||||
export * from './usePermission';
|
||||
export * from './usePermission';
|
||||
export * from './usePublicSettings';
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
||||
import { de, enUS, ptBR } from 'date-fns/locale';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { publicSettingsService } from '../services/publicSettings.service';
|
||||
import { usePublicSettings } from './usePublicSettings';
|
||||
|
||||
// Convert old date format strings to new date-fns format
|
||||
const convertDateFormat = (format: string): string => {
|
||||
@@ -15,13 +14,7 @@ const convertDateFormat = (format: string): string => {
|
||||
export const useLocalizedDate = () => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Fetch public settings to get the date format
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: () => publicSettingsService.getPublicSettings(),
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
retry: 1, // Only retry once to avoid blocking the UI
|
||||
});
|
||||
const { data: settings } = usePublicSettings();
|
||||
|
||||
const getLocale = () => {
|
||||
if (i18n.language === 'de') return de;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery, type UseQueryOptions } from '@tanstack/react-query';
|
||||
import { publicSettingsService, type PublicSettings } from '../services/publicSettings.service';
|
||||
|
||||
export const PUBLIC_SETTINGS_QUERY_KEY = ['public-settings'] as const;
|
||||
|
||||
type PublicSettingsQueryOptions = Omit<
|
||||
UseQueryOptions<PublicSettings, Error>,
|
||||
'queryKey' | 'queryFn'
|
||||
>;
|
||||
|
||||
export function usePublicSettings(options?: PublicSettingsQueryOptions) {
|
||||
return useQuery<PublicSettings, Error>({
|
||||
queryKey: PUBLIC_SETTINGS_QUERY_KEY,
|
||||
queryFn: () => publicSettingsService.getPublicSettings(),
|
||||
staleTime: 60_000,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,27 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../config/api';
|
||||
import { usePublicSettings } from './usePublicSettings';
|
||||
|
||||
export function useWatermarkSettings() {
|
||||
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data: settings, isLoading } = usePublicSettings();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
// Use public settings endpoint that doesn't require authentication
|
||||
const response = await api.get('/public/settings');
|
||||
setWatermarkEnabled(response.data.branding_watermark_enabled || false);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch watermark settings:', error);
|
||||
// Default to false if we can't fetch settings
|
||||
setWatermarkEnabled(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
return { watermarkEnabled, loading };
|
||||
}
|
||||
return {
|
||||
watermarkEnabled: Boolean(settings?.branding_watermark_enabled),
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user