39948-vm/frontend/src/hooks/usePageDataLoader.ts
2026-03-28 17:11:39 +04:00

254 lines
7.0 KiB
TypeScript

/**
* usePageDataLoader Hook
*
* Unified hook for loading project and page data in presentation components.
* Used by both RuntimePresentation (public) and constructor.tsx (authenticated).
*
* Features:
* - Loads project by slug or ID
* - Loads pages filtered by environment
* - Sorts pages by sort_order
* - Handles loading and error states
* - Supports reloading with page preservation
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import axios from 'axios';
import { logger } from '../lib/logger';
import type { RuntimeProject, RuntimePage } from '../types/runtime';
/**
* Configuration for the page data loader
*/
export interface UsePageDataLoaderOptions {
/** Project ID for authenticated mode (constructor) */
projectId?: string;
/** Project slug for public mode (runtime) */
projectSlug?: string;
/** Environment to filter pages by */
environment: 'dev' | 'stage' | 'production';
/** Whether the data loading should be enabled */
enabled?: boolean;
/** Custom API headers (e.g., for runtime environment context) */
apiHeaders?: Record<string, string>;
/** Initial page ID from route (for constructor) */
initialPageId?: string;
}
/**
* Result of the page data loader
*/
export interface UsePageDataLoaderResult {
/** Loaded project data */
project: RuntimeProject | null;
/** Loaded and filtered pages */
pages: RuntimePage[];
/** Whether data is currently loading */
isLoading: boolean;
/** Error message if loading failed */
error: string;
/** Reload the data (optionally preserving current page selection) */
reload: (preservePageId?: string) => Promise<void>;
/** Initially selected page ID */
initialPageId: string;
}
/**
* Extract rows from API response
*/
const getRows = (response: unknown): unknown[] => {
const data = response as { data?: { rows?: unknown[] } };
return Array.isArray(data?.data?.rows) ? data.data.rows : [];
};
/**
* Hook for loading project and page data.
*
* @example
* // Runtime mode (public presentation)
* const { project, pages, isLoading, error } = usePageDataLoader({
* projectSlug: 'my-project',
* environment: 'production',
* });
*
* @example
* // Constructor mode (authenticated)
* const { project, pages, isLoading, error, reload } = usePageDataLoader({
* projectId: 'uuid-here',
* environment: 'dev',
* enabled: isAuthReady,
* });
*/
export function usePageDataLoader({
projectId,
projectSlug,
environment,
enabled = true,
apiHeaders = {},
initialPageId: initialPageIdFromProps = '',
}: UsePageDataLoaderOptions): UsePageDataLoaderResult {
const [project, setProject] = useState<RuntimeProject | null>(null);
const [pages, setPages] = useState<RuntimePage[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [selectedInitialPageId, setSelectedInitialPageId] = useState('');
// Memoize API config to prevent unnecessary reloads
const apiConfig = useMemo(
() => ({
headers: apiHeaders,
}),
// Serialize headers for comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(apiHeaders)],
);
/**
* Load project and pages data
*/
const loadData = useCallback(
async (preservePageId?: string) => {
// Need either projectId or projectSlug
if (!projectId && !projectSlug) {
setError('No project identifier provided.');
setIsLoading(false);
return;
}
if (!enabled) {
return;
}
try {
setIsLoading(true);
setError('');
let foundProject: RuntimeProject | null = null;
// Load by ID (constructor mode)
if (projectId) {
const projectResponse = await axios.get(
`/projects/${projectId}`,
apiConfig,
);
foundProject = projectResponse.data;
}
// Load by slug (runtime mode)
else if (projectSlug) {
const projectsResponse = await axios.get('/projects', {
...apiConfig,
params: { slug: projectSlug },
});
const projectRows = getRows(projectsResponse) as RuntimeProject[];
foundProject =
projectRows.find((p) => p.slug === projectSlug) || null;
if (!foundProject) {
setError(`Project "${projectSlug}" not found.`);
setIsLoading(false);
return;
}
}
if (!foundProject) {
setError('Project not found.');
setIsLoading(false);
return;
}
setProject(foundProject);
// Load pages for this project
const pagesParams: Record<string, string> = {
project: foundProject.id,
};
// For constructor mode, also filter by environment in params
if (projectId) {
pagesParams.environment = environment;
pagesParams.limit = '500';
pagesParams.sort = 'asc';
pagesParams.field = 'sort_order';
}
const pagesResponse = await axios.get('/tour_pages', {
...apiConfig,
params: pagesParams,
});
let pageRows = getRows(pagesResponse) as RuntimePage[];
// For runtime mode, filter by environment client-side
// (backend may not have environment header support)
if (projectSlug) {
pageRows = pageRows.filter((p) => p.environment === environment);
}
// Sort by sort_order
pageRows.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
setPages(pageRows);
// Determine initial page
const preservedPageExists =
preservePageId && pageRows.some((p) => p.id === preservePageId);
const defaultPageId = preservedPageExists
? preservePageId
: initialPageIdFromProps ||
(pageRows.length > 0 ? pageRows[0].id : '');
setSelectedInitialPageId(defaultPageId);
} catch (err: unknown) {
const axiosError = err as {
response?: { status?: number; data?: { message?: string } };
message?: string;
};
// Handle authentication errors
if (axiosError?.response?.status === 401) {
setError('Your session has expired. Please sign in again.');
logger.error('Unauthorized request during data load');
return;
}
const message =
axiosError?.response?.data?.message ||
axiosError?.message ||
'Failed to load presentation data.';
logger.error(
'Failed to load page data:',
err instanceof Error ? err : { error: err },
);
setError(message);
setPages([]);
} finally {
setIsLoading(false);
}
},
[
projectId,
projectSlug,
environment,
enabled,
apiConfig,
initialPageIdFromProps,
],
);
// Initial load
useEffect(() => {
loadData();
}, [loadData]);
return {
project,
pages,
isLoading,
error,
reload: loadData,
initialPageId: selectedInitialPageId,
};
}