Extract useUrlState hook from view_page.tsx

This commit is contained in:
Przemek Więch
2026-06-23 23:41:27 +02:00
parent 2f896feac1
commit 5037175727
2 changed files with 131 additions and 68 deletions

106
src/hooks/use_url_state.ts Normal file
View File

@@ -0,0 +1,106 @@
import {useCallback, useMemo} from 'react';
import {useLocation, useNavigate} from 'react-router';
import {IndiInfo} from 'topola';
import {PRIVATE_ID_PREFIX} from '../datasource/wikitree';
import {Config, configToArgs} from '../sidepanel/config/config';
import {analyticsEvent} from '../util/analytics';
import {getArguments, getUrlForArgs} from '../util/url_args';
/**
* Custom React hook to manage and synchronize URL routing and query parameter state
* for the genealogy chart workspace.
*/
export function useUrlState() {
const location = useLocation();
const navigate = useNavigate();
// Parse all URL arguments memoized on location changes.
const args = useMemo(() => getArguments(location), [location]);
/**
* Helper to update the browser URL query parameters.
*/
const updateUrl = useCallback(
(
newArgs: Record<string, string | (string | null)[] | null | undefined>,
options?: {replace?: boolean},
) => {
navigate(getUrlForArgs(location, newArgs), options);
},
[location, navigate],
);
/**
* Called when the user clicks an individual box in the chart.
* Updates the browser URL with the new primary individual selection.
*/
const onSelection = useCallback(
(selection: IndiInfo) => {
// Don't allow selecting WikiTree private profiles.
if (selection.id.startsWith(PRIVATE_ID_PREFIX)) {
return;
}
analyticsEvent('selection_changed');
updateUrl({
indi: selection.id,
gen: String(selection.generation),
detail: null,
});
},
[updateUrl],
);
/**
* Called when the user shift+clicks an individual box in the chart.
* Shows the individual in the details pane.
*/
const onDetailSelection = useCallback(
(selection: IndiInfo) => {
updateUrl({
detail: selection.id,
});
},
[updateUrl],
);
/**
* Toggles the side panel expansion state.
*/
const onToggleSidePanel = useCallback(() => {
const newShowSidePanel = !args.showSidePanel;
updateUrl(
{
sidePanel: newShowSidePanel ? 'true' : 'false',
},
{replace: true},
);
}, [args.showSidePanel, updateUrl]);
/**
* Updates the URL parameters based on configuration updates.
*/
const onConfigChange = useCallback(
(config: Config) => {
updateUrl(configToArgs(config), {replace: true});
},
[updateUrl],
);
return {
args,
chartType: args.chartType,
standalone: args.standalone,
showWikiTreeMenus: args.showWikiTreeMenus,
freezeAnimation: args.freezeAnimation,
showSidePanel: args.showSidePanel,
config: args.config,
sourceSpec: args.sourceSpec,
selection: args.selection,
detail: args.detail,
updateUrl,
onSelection,
onDetailSelection,
onToggleSidePanel,
onConfigChange,
};
}

View File

@@ -39,22 +39,22 @@ import {
} from '../datasource/load_data';
import {
loadWikiTree,
PRIVATE_ID_PREFIX,
WikiTreeDataSource,
WikiTreeSourceSpec,
} from '../datasource/wikitree';
import {DonatsoChart} from '../donatso-chart';
import {useGoogleAuth} from '../hooks/use_google_auth';
import {useUrlState} from '../hooks/use_url_state';
import {useWebMcpBridge} from '../hooks/use_webmcp_bridge';
import {GoogleAuthModal} from '../menu/google_auth_modal';
import {TopBar} from '../menu/top_bar';
import {Config, configToArgs, Ids, Sex} from '../sidepanel/config/config';
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, getUrlForArgs} from '../util/url_args';
import {DataSourceSpec, getArguments} from '../util/url_args';
export enum AppState {
INITIAL,
@@ -92,36 +92,36 @@ export function ViewPage() {
/** Tracks whether the user has a valid cached Google Drive OAuth access token and provides a state setter. */
const {hasGoogleToken, setHasGoogleToken} = useGoogleAuth();
const {
chartType,
standalone,
showWikiTreeMenus,
freezeAnimation,
showSidePanel,
config,
selection: urlSelection,
detail: urlDetail,
onSelection,
onDetailSelection,
onToggleSidePanel,
onConfigChange,
} = useUrlState();
const intl = useIntl();
const navigate = useNavigate();
const location = useLocation();
const args = useMemo(() => getArguments(location), [location]);
/** Type of displayed chart. */
const chartType = args.chartType;
/** Whether the app is in standalone mode, i.e. showing 'open file' menus. */
const standalone = args.standalone;
/**
* Whether the app should display WikiTree-specific menus when showing data
* from WikiTree.
*/
const showWikiTreeMenus = args.showWikiTreeMenus;
/** Freeze animations after initial chart render. */
const freezeAnimation = args.freezeAnimation;
/** Whether the side panel is shown. */
const showSidePanel = args.showSidePanel;
/** Configuration settings for chart display options (e.g. colors, hiding IDs). */
const config = args.config;
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, args.selection) : undefined;
}, [data, args.selection]);
return data ? getSelection(data.chartData, urlSelection) : undefined;
}, [data, urlSelection]);
/** The individual displayed in the details pane. */
const detailIndi = args.detail || updatedSelection?.id;
const detailIndi = urlDetail || updatedSelection?.id;
/** Tracks whether the component is currently mounted to prevent state updates after unmount. */
const isMountedRef = useRef(true);
@@ -151,15 +151,7 @@ export function ViewPage() {
});
}
function onToggleSidePanel() {
const newShowSidePanel = !showSidePanel;
updateUrl(
{
sidePanel: newShowSidePanel ? 'true' : 'false',
},
{replace: true},
);
}
/** Sets error message after data load failure. */
/** Sets error message after data load failure. */
function setErrorMessage(message: string) {
@@ -402,39 +394,6 @@ export function ViewPage() {
useWebMcpBridge(data, detailIndi, onSelection);
function updateUrl(
args: Record<string, string | (string | null)[] | null | undefined>,
options?: {replace?: boolean},
) {
navigate(getUrlForArgs(location, args), options);
}
/**
* Called when the user clicks an individual box in the chart.
* Updates the browser URL.
*/
function onSelection(selection: IndiInfo) {
// Don't allow selecting WikiTree private profiles.
if (selection.id.startsWith(PRIVATE_ID_PREFIX)) {
return;
}
analyticsEvent('selection_changed');
updateUrl({
indi: selection.id,
gen: String(selection.generation),
detail: null,
});
}
/**
* Called when the user shift+clicks an individual box in the chart.
* Shows the individual in the details pane.
*/
function onDetailSelection(selection: IndiInfo) {
updateUrl({
detail: selection.id,
});
}
function onPrint() {
analyticsEvent('print');
printChart();
@@ -543,9 +502,7 @@ export function ViewPage() {
config={config}
expanded={showSidePanel}
onToggle={onToggleSidePanel}
onConfigChange={(config) => {
updateUrl(configToArgs(config), {replace: true});
}}
onConfigChange={onConfigChange}
/>
<SidebarPusher>{renderChart(selection)}</SidebarPusher>
</SidebarPushable>