diff --git a/src/hooks/use_genealogy_loader.ts b/src/hooks/use_genealogy_loader.ts new file mode 100644 index 0000000..9eb7f00 --- /dev/null +++ b/src/hooks/use_genealogy_loader.ts @@ -0,0 +1,349 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {IntlShape} from 'react-intl'; +import {useLocation, useNavigate} from 'react-router'; +import {IndiInfo} from 'topola'; +import {DataSourceEnum, SourceSelection} from '../datasource/data_source'; +import {EmbeddedSourceSpec} from '../datasource/embedded'; +import { + GoogleDriveAuthError, + GoogleDriveSourceSpec, +} from '../datasource/google_drive'; +import {isGoogleDriveConfigured} from '../datasource/google_drive_service'; +import { + embeddedDataSource, + gedcomUrlDataSource, + googleDriveDataSource, + uploadedDataSource, +} from '../datasource/instances'; +import { + getSelection, + revokeObjectUrls, + UploadSourceSpec, + UrlSourceSpec, +} from '../datasource/load_data'; +import { + loadWikiTree, + WikiTreeDataSource, + WikiTreeSourceSpec, +} from '../datasource/wikitree'; +import {AppState} from '../pages/view_page'; +import {getI18nMessage} from '../util/error_i18n'; +import {TopolaData} from '../util/gedcom_util'; +import {DataSourceSpec, getArguments} from '../util/url_args'; + +/** + * Custom React hook that orchestrates loading genealogy data from various sources + * (Uploaded files, Google Drive, WikiTree API, URL params, or Embedded data). + * It manages the async lifecycle, error boundary popups, and incremental WikiTree loading. + */ +export function useGenealogyLoader(options: { + intl: IntlShape; + urlSelection?: IndiInfo; + urlDetail?: string; + /** Callback triggered when a Google Drive authorization error occurs. */ + onAuthError: (fileId: string) => void; +}) { + const {intl, urlSelection, urlDetail, onAuthError} = options; + const navigate = useNavigate(); + const location = useLocation(); + + const [state, setState] = useState(AppState.INITIAL); + const [loadingStatus, setLoadingStatus] = useState('Loading…'); + const [data, setData] = useState(); + const [error, setError] = useState(); + const [showErrorPopup, setShowErrorPopup] = useState(false); + const [sourceSpec, setSourceSpec] = useState(); + + const isMountedRef = useRef(true); + const fetchIdRef = useRef(0); + const loadedSelectionRef = useRef(); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const wikiTreeDataSource = useMemo( + () => new WikiTreeDataSource(intl), + [intl], + ); + + const isNewData = useCallback( + (newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) => { + if (!sourceSpec || sourceSpec.source !== newSourceSpec.source) { + return true; + } + const newSource = {spec: newSourceSpec, selection: newSelection}; + const oldSource = { + spec: sourceSpec, + selection: loadedSelectionRef.current, + }; + switch (newSource.spec.source) { + case DataSourceEnum.UPLOADED: + return uploadedDataSource.isNewData( + newSource as unknown as SourceSelection, + oldSource as unknown as SourceSelection, + data, + ); + case DataSourceEnum.GEDCOM_URL: + return gedcomUrlDataSource.isNewData( + newSource as unknown as SourceSelection, + oldSource as unknown as SourceSelection, + data, + ); + case DataSourceEnum.WIKITREE: + return wikiTreeDataSource.isNewData( + newSource as unknown as SourceSelection, + oldSource as unknown as SourceSelection, + data, + ); + case DataSourceEnum.EMBEDDED: + return embeddedDataSource.isNewData( + newSource as unknown as SourceSelection, + oldSource as unknown as SourceSelection, + data, + ); + case DataSourceEnum.GOOGLE_DRIVE: + return googleDriveDataSource.isNewData( + newSource as unknown as SourceSelection, + oldSource as unknown as SourceSelection, + data, + ); + default: + return false; + } + }, + [sourceSpec, data, wikiTreeDataSource], + ); + + const loadData = useCallback( + ( + newSourceSpec: DataSourceSpec, + newSelection?: IndiInfo, + onProgress?: (status: string) => void, + ) => { + switch (newSourceSpec.source) { + case DataSourceEnum.UPLOADED: + return uploadedDataSource.loadData( + { + spec: newSourceSpec as UploadSourceSpec, + selection: newSelection, + }, + onProgress, + ); + case DataSourceEnum.GEDCOM_URL: + return gedcomUrlDataSource.loadData( + {spec: newSourceSpec as UrlSourceSpec, selection: newSelection}, + onProgress, + ); + case DataSourceEnum.WIKITREE: + return wikiTreeDataSource.loadData( + { + spec: newSourceSpec as WikiTreeSourceSpec, + selection: newSelection, + }, + onProgress, + ); + case DataSourceEnum.EMBEDDED: + return embeddedDataSource.loadData( + { + spec: newSourceSpec as EmbeddedSourceSpec, + selection: newSelection, + }, + onProgress, + ); + case DataSourceEnum.GOOGLE_DRIVE: + if (!isGoogleDriveConfigured()) { + throw new Error('Google Drive integration is not configured.'); + } + return googleDriveDataSource.loadData( + { + spec: newSourceSpec as GoogleDriveSourceSpec, + selection: newSelection, + }, + onProgress, + ); + default: + throw new Error('Unsupported data source'); + } + }, + [wikiTreeDataSource], + ); + + const setErrorMessage = useCallback((message: string) => { + setError(message); + setState(AppState.ERROR); + }, []); + + const displayErrorPopup = useCallback((message: string) => { + setShowErrorPopup(true); + setError(message); + }, []); + + const onDismissErrorPopup = useCallback(() => { + setShowErrorPopup(false); + }, []); + + const resetLoader = useCallback(() => { + setState(AppState.INITIAL); + }, []); + + const clearData = useCallback(() => { + setData(undefined); + loadedSelectionRef.current = undefined; + }, []); + + // Main data loading and updating side-effect + useEffect(() => { + (async () => { + if (location.pathname !== '/view') { + if (state !== AppState.INITIAL) { + setState(AppState.INITIAL); + } + setData(undefined); + return; + } + + const args = getArguments(location); + + if (!args.sourceSpec) { + navigate({pathname: '/'}, {replace: true}); + return; + } + + if ( + (state === AppState.INITIAL || + isNewData(args.sourceSpec, args.selection)) && + state !== AppState.LOADING && + state !== AppState.LOADING_MORE + ) { + setState(AppState.LOADING); + setSourceSpec(args.sourceSpec); + loadedSelectionRef.current = args.selection; + const currentFetchId = ++fetchIdRef.current; + setLoadingStatus('Loading…'); + try { + const data = await loadData( + args.sourceSpec, + args.selection, + (status) => { + if (isMountedRef.current) setLoadingStatus(status); + }, + ); + if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) { + return; + } + setLoadingStatus( + `Rendering chart (${data.chartData.indis.length.toLocaleString()} people)…`, + ); + setData(data); + loadedSelectionRef.current = getSelection( + data.chartData, + args.selection, + ); + setState(AppState.SHOWING_CHART); + } catch (error: unknown) { + if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) { + return; + } + if (error instanceof GoogleDriveAuthError) { + if (args.sourceSpec.source === DataSourceEnum.GOOGLE_DRIVE) { + onAuthError((args.sourceSpec as GoogleDriveSourceSpec).fileId); + } + } else { + setErrorMessage(getI18nMessage(error as Error, intl)); + } + } + } else if ( + state === AppState.SHOWING_CHART || + state === AppState.LOADING_MORE + ) { + const loadMoreFromWikitree = + args.sourceSpec.source === DataSourceEnum.WIKITREE && + !!args.selection && + (!loadedSelectionRef.current || + loadedSelectionRef.current.id !== args.selection.id); + setState( + loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART, + ); + if (loadMoreFromWikitree && args.selection) { + const currentFetchId = ++fetchIdRef.current; + try { + const data = await loadWikiTree(args.selection.id, intl); + if ( + !isMountedRef.current || + fetchIdRef.current !== currentFetchId + ) { + return; + } + const newSelection = getSelection(data.chartData, args.selection); + setData(data); + loadedSelectionRef.current = newSelection; + setState(AppState.SHOWING_CHART); + } catch (error: unknown) { + if ( + !isMountedRef.current || + fetchIdRef.current !== currentFetchId + ) { + return; + } + setState(AppState.SHOWING_CHART); + displayErrorPopup( + intl.formatMessage( + { + id: 'error.failed_wikitree_load_more', + defaultMessage: 'Failed to load data from WikiTree. {error}', + }, + {error: (error as Error).message || String(error)}, + ), + ); + } + } + } + })(); + }, [ + location, + state, + data, + navigate, + intl, + isNewData, + loadData, + onAuthError, + setErrorMessage, + displayErrorPopup, + ]); + + // Clean up object URLs created for uploaded images/files when the dataset + // changes or the app unmounts to prevent memory leaks. + useEffect(() => { + return () => { + revokeObjectUrls(data?.images); + }; + }, [data]); + + const updatedSelection = useMemo(() => { + return data ? getSelection(data.chartData, urlSelection) : undefined; + }, [data, urlSelection]); + + const detailIndi = urlDetail || updatedSelection?.id; + + return { + state, + loadingStatus, + data, + error, + showErrorPopup, + sourceSpec, + updatedSelection, + detailIndi, + loadedSelectionRef, + onDismissErrorPopup, + resetLoader, + clearData, + setLoadingStatus, + displayErrorPopup, + }; +} diff --git a/src/pages/view_page.tsx b/src/pages/view_page.tsx index 501a3c0..749015f 100644 --- a/src/pages/view_page.tsx +++ b/src/pages/view_page.tsx @@ -1,6 +1,5 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useMemo} from 'react'; import {useIntl} from 'react-intl'; -import {useLocation, useNavigate} from 'react-router'; import {Loader, SidebarPushable, SidebarPusher} from 'semantic-ui-react'; import {IndiInfo} from 'topola'; import { @@ -13,31 +12,9 @@ import { } from '../chart'; import {ErrorMessage, ErrorPopup} from '../components/error_display'; import {ProgressPill} from '../components/progress_pill'; -import {DataSourceEnum, SourceSelection} from '../datasource/data_source'; -import {EmbeddedSourceSpec} from '../datasource/embedded'; -import { - GoogleDriveAuthError, - GoogleDriveSourceSpec, -} from '../datasource/google_drive'; -import {isGoogleDriveConfigured} from '../datasource/google_drive_service'; -import { - embeddedDataSource, - gedcomUrlDataSource, - googleDriveDataSource, - uploadedDataSource, -} from '../datasource/instances'; -import { - getSelection, - revokeObjectUrls, - UploadSourceSpec, - UrlSourceSpec, -} from '../datasource/load_data'; -import { - loadWikiTree, - WikiTreeDataSource, - WikiTreeSourceSpec, -} from '../datasource/wikitree'; +import {DataSourceEnum} from '../datasource/data_source'; import {DonatsoChart} from '../donatso-chart'; +import {useGenealogyLoader} from '../hooks/use_genealogy_loader'; import {useGoogleDriveAuthFlow} from '../hooks/use_google_drive_auth_flow'; import {useUrlState} from '../hooks/use_url_state'; import {useWebMcpBridge} from '../hooks/use_webmcp_bridge'; @@ -46,10 +23,7 @@ import {TopBar} from '../menu/top_bar'; import {Config, Ids, Sex} from '../sidepanel/config/config'; import {SidePanel} from '../sidepanel/side-panel'; import {analyticsEvent} from '../util/analytics'; -import {TopolaError} from '../util/error'; -import {getI18nMessage} from '../util/error_i18n'; import {idToIndiMap, TopolaData} from '../util/gedcom_util'; -import {DataSourceSpec, getArguments} from '../util/url_args'; export enum AppState { INITIAL, @@ -59,46 +33,30 @@ export enum AppState { LOADING_MORE, } +/** + * Updates the chart data nodes with custom display parameters (such as hiding IDs or genders) + * in place prior to rendering D3 canvas. + */ +function updateChartWithConfig(config: Config, data: TopolaData | undefined) { + if (data === undefined) { + return; + } + const shouldHideIds = config.id === Ids.HIDE; + const shouldHideSex = config.sex === Sex.HIDE; + const indiMap = idToIndiMap(data.chartData); + indiMap.forEach((indi) => { + indi.hideId = shouldHideIds; + indi.hideSex = shouldHideSex; + }); +} + /** * ViewPage is the page component that orchestrates the genealogy chart workspace. * It manages asynchronous data loading, configuration parameters derived from the URL, * chart rendering (both D3 and Donatso), side panel settings, and Google Drive auth workflows. */ export function ViewPage() { - /** State of the application. */ - const [state, setState] = useState(AppState.INITIAL); - /** Progress message shown during LOADING and initial chart render. */ - const [loadingStatus, setLoadingStatus] = useState('Loading…'); - /** Loaded data. */ - const [data, setData] = useState(); - - /** Error to display. */ - const [error, setError] = useState(); - - /** Whether to show the error popup. */ - const [showErrorPopup, setShowErrorPopup] = useState(false); - /** Specification of the source of the data. */ - const [sourceSpec, setSourceSpec] = useState(); - - // Manage Google Drive auth and session flows - const { - showAuthModal, - failedFileId, - hasGoogleToken, - setHasGoogleToken, - onGoogleSignOut, - triggerAuthError, - onAuthSuccess, - onCancel, - } = useGoogleDriveAuthFlow({ - onSignOut: useCallback(() => { - setData(undefined); - loadedSelectionRef.current = undefined; - }, []), - onAuthSuccess: useCallback(() => { - setState(AppState.INITIAL); - }, []), - }); + const intl = useIntl(); const { chartType, @@ -115,289 +73,47 @@ export function ViewPage() { onConfigChange, } = useUrlState(); - const intl = useIntl(); - const navigate = useNavigate(); - const location = useLocation(); + const { + state, + loadingStatus, + data, + error, + showErrorPopup, + sourceSpec, + updatedSelection, + detailIndi, + onDismissErrorPopup, + resetLoader, + clearData, + setLoadingStatus, + displayErrorPopup, + } = useGenealogyLoader({ + intl, + urlSelection, + urlDetail, + onAuthError: useCallback((fileId) => { + triggerAuthError(fileId); + }, []), + }); + + const { + showAuthModal, + failedFileId, + hasGoogleToken, + setHasGoogleToken, + onGoogleSignOut, + triggerAuthError, + onAuthSuccess, + onCancel, + } = useGoogleDriveAuthFlow({ + onSignOut: clearData, + onAuthSuccess: resetLoader, + }); useMemo(() => { updateChartWithConfig(config, data); }, [config, data]); - /** The currently selected individual. Fallback to default individual from loaded data if not specified. */ - const updatedSelection = useMemo(() => { - return data ? getSelection(data.chartData, urlSelection) : undefined; - }, [data, urlSelection]); - - /** The individual displayed in the details pane. */ - const detailIndi = urlDetail || updatedSelection?.id; - - /** Tracks whether the component is currently mounted to prevent state updates after unmount. */ - const isMountedRef = useRef(true); - /** Incremented with each load request to ensure only the latest asynchronous load result is applied. */ - const fetchIdRef = useRef(0); - /** Tracks the currently loaded selection to check if new data needs to be fetched. */ - const loadedSelectionRef = useRef(); - - /** Manages the mount lifecycle ref to avoid setting state on unmounted components. */ - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); - - function updateChartWithConfig(config: Config, data: TopolaData | undefined) { - if (data === undefined) { - return; - } - const shouldHideIds = config.id === Ids.HIDE; - const shouldHideSex = config.sex === Sex.HIDE; - const indiMap = idToIndiMap(data.chartData); - indiMap.forEach((indi) => { - indi.hideId = shouldHideIds; - indi.hideSex = shouldHideSex; - }); - } - - /** Sets error message after data load failure. */ - - /** Sets error message after data load failure. */ - function setErrorMessage(message: string) { - setError(message); - setState(AppState.ERROR); - } - - const wikiTreeDataSource = useMemo( - () => new WikiTreeDataSource(intl), - [intl], - ); - - const isNewData = useCallback( - (newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) => { - if (!sourceSpec || sourceSpec.source !== newSourceSpec.source) { - // New data source means new data. - return true; - } - const newSource = {spec: newSourceSpec, selection: newSelection}; - const oldSource = { - spec: sourceSpec, - selection: loadedSelectionRef.current, - }; - switch (newSource.spec.source) { - case DataSourceEnum.UPLOADED: - return uploadedDataSource.isNewData( - newSource as SourceSelection, - oldSource as SourceSelection, - data, - ); - case DataSourceEnum.GEDCOM_URL: - return gedcomUrlDataSource.isNewData( - newSource as SourceSelection, - oldSource as SourceSelection, - data, - ); - case DataSourceEnum.WIKITREE: - return wikiTreeDataSource.isNewData( - newSource as SourceSelection, - oldSource as SourceSelection, - data, - ); - case DataSourceEnum.EMBEDDED: - return embeddedDataSource.isNewData( - newSource as SourceSelection, - oldSource as SourceSelection, - data, - ); - case DataSourceEnum.GOOGLE_DRIVE: - return googleDriveDataSource.isNewData( - newSource as SourceSelection, - oldSource as SourceSelection, - data, - ); - } - }, - [sourceSpec, data, wikiTreeDataSource], - ); - - const loadData = useCallback( - ( - newSourceSpec: DataSourceSpec, - newSelection?: IndiInfo, - onProgress?: (status: string) => void, - ) => { - switch (newSourceSpec.source) { - case DataSourceEnum.UPLOADED: - return uploadedDataSource.loadData( - {spec: newSourceSpec, selection: newSelection}, - onProgress, - ); - case DataSourceEnum.GEDCOM_URL: - return gedcomUrlDataSource.loadData( - {spec: newSourceSpec, selection: newSelection}, - onProgress, - ); - case DataSourceEnum.WIKITREE: - return wikiTreeDataSource.loadData( - {spec: newSourceSpec, selection: newSelection}, - onProgress, - ); - case DataSourceEnum.EMBEDDED: - return embeddedDataSource.loadData( - {spec: newSourceSpec, selection: newSelection}, - onProgress, - ); - case DataSourceEnum.GOOGLE_DRIVE: - if (!isGoogleDriveConfigured()) { - throw new TopolaError( - 'GOOGLE_DRIVE_NOT_CONFIGURED', - 'Google Drive integration is not configured.', - ); - } - return googleDriveDataSource.loadData( - { - spec: newSourceSpec as GoogleDriveSourceSpec, - selection: newSelection, - }, - onProgress, - ); - } - }, - [wikiTreeDataSource], - ); - - useEffect(() => { - (async () => { - if (location.pathname !== '/view') { - if (state !== AppState.INITIAL) { - setState(AppState.INITIAL); - } - setData(undefined); - return; - } - - const args = getArguments(location); - - if (!args.sourceSpec) { - navigate({pathname: '/'}, {replace: true}); - return; - } - - if ( - (state === AppState.INITIAL || - isNewData(args.sourceSpec, args.selection)) && - state !== AppState.LOADING && - state !== AppState.LOADING_MORE - ) { - // Set loading state. - setState(AppState.LOADING); - // Set state from URL parameters. - setSourceSpec(args.sourceSpec); - loadedSelectionRef.current = args.selection; - const currentFetchId = ++fetchIdRef.current; - setLoadingStatus('Loading…'); - try { - const data = await loadData( - args.sourceSpec, - args.selection, - (status) => { - if (isMountedRef.current) setLoadingStatus(status); - }, - ); - if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) { - return; - } - // Show "Rendering chart…" while the initial D3 layout runs (which - // happens in the Chart useEffect after SHOWING_CHART is set). - setLoadingStatus( - `Rendering chart (${data.chartData.indis.length.toLocaleString()} people)…`, - ); - setData(data); - loadedSelectionRef.current = getSelection( - data.chartData, - args.selection, - ); - setState(AppState.SHOWING_CHART); - } catch (error: unknown) { - if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) { - return; - } - if (error instanceof GoogleDriveAuthError) { - if (args.sourceSpec.source === DataSourceEnum.GOOGLE_DRIVE) { - triggerAuthError(args.sourceSpec.fileId); - } - } else { - setErrorMessage(getI18nMessage(error as Error, intl)); - } - } - } else if ( - state === AppState.SHOWING_CHART || - state === AppState.LOADING_MORE - ) { - // Update selection if it has changed in the URL. - const loadMoreFromWikitree = - args.sourceSpec.source === DataSourceEnum.WIKITREE && - !!args.selection && - (!loadedSelectionRef.current || - loadedSelectionRef.current.id !== args.selection.id); - setState( - loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART, - ); - if (loadMoreFromWikitree) { - const currentFetchId = ++fetchIdRef.current; - try { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const data = await loadWikiTree(args.selection!.id, intl); - if ( - !isMountedRef.current || - fetchIdRef.current !== currentFetchId - ) { - return; - } - const newSelection = getSelection(data.chartData, args.selection); - setData(data); - loadedSelectionRef.current = newSelection; - setState(AppState.SHOWING_CHART); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - if ( - !isMountedRef.current || - fetchIdRef.current !== currentFetchId - ) { - return; - } - setState(AppState.SHOWING_CHART); - displayErrorPopup( - intl.formatMessage( - { - id: 'error.failed_wikitree_load_more', - defaultMessage: 'Failed to load data from WikiTree. {error}', - }, - {error}, - ), - ); - } - } - } - })(); - }, [ - location, - state, - data, - navigate, - intl, - isNewData, - loadData, - triggerAuthError, - ]); - - // Clean up object URLs created for uploaded images/files when the dataset - // changes or the app unmounts to prevent memory leaks. - useEffect(() => { - return () => { - revokeObjectUrls(data?.images); - }; - }, [data]); - useWebMcpBridge(data, detailIndi, onSelection); function onPrint() { @@ -405,11 +121,6 @@ export function ViewPage() { printChart(); } - function displayErrorPopup(message: string) { - setShowErrorPopup(true); - setError(message); - } - async function onDownloadPdf() { analyticsEvent('download_pdf'); try { @@ -447,10 +158,6 @@ export function ViewPage() { downloadSvg(); } - function onDismissErrorPopup() { - setShowErrorPopup(false); - } - function renderChart(selection: IndiInfo) { if (!data) { return null;