mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-17 17:21:48 +00:00
Extract url utils from app.tsx
This commit is contained in:
@@ -82,7 +82,7 @@ To refactor `app.tsx` safely and maintain continuous code correctness, we will m
|
|||||||
#### Phase 1: Pure Component and Utility Extraction
|
#### Phase 1: Pure Component and Utility Extraction
|
||||||
* [x] **Step 1.1:** Create `src/components/error_display.tsx` and move `ErrorMessage` and `ErrorPopup` from `src/app.tsx`. Update imports in `src/app.tsx`.
|
* [x] **Step 1.1:** Create `src/components/error_display.tsx` and move `ErrorMessage` and `ErrorPopup` from `src/app.tsx`. Update imports in `src/app.tsx`.
|
||||||
* [x] **Step 1.2:** Create `src/datasource/instances.ts` and move data source class instantiations. Update references in `src/app.tsx`. Refactor `EmbeddedDataSource` to clean up its message event listener when the loading promise resolves or rejects (or track listener state) to prevent duplicate event listener leaks on multiple page mounts.
|
* [x] **Step 1.2:** Create `src/datasource/instances.ts` and move data source class instantiations. Update references in `src/app.tsx`. Refactor `EmbeddedDataSource` to clean up its message event listener when the loading promise resolves or rejects (or track listener state) to prevent duplicate event listener leaks on multiple page mounts.
|
||||||
* [ ] **Step 1.3:** Create `src/util/url_args.ts` (the parsing utility) and `src/util/url_args.spec.ts` (its Jest unit test suite) together. Extract URL query parameter parsing functions and types from `src/app.tsx`, write comprehensive tests, update imports in `src/app.tsx`, and run `npm test` to verify.
|
* [x] **Step 1.3:** Create `src/util/url_args.ts` (the parsing utility) and `src/util/url_args.spec.ts` (its Jest unit test suite) together. Extract URL query parameter parsing functions and types from `src/app.tsx`, write comprehensive tests, update imports in `src/app.tsx`, and run `npm test` to verify.
|
||||||
* [ ] **Step 1.4:** Modify `src/menu/top_bar.tsx` to make chart-specific props and event handlers optional (e.g. `data`, `allowAllRelativesChart`, `allowPrintAndDownload`, `eventHandlers`, etc.), preparing the component for rendering on the landing screen without dummy properties.
|
* [ ] **Step 1.4:** Modify `src/menu/top_bar.tsx` to make chart-specific props and event handlers optional (e.g. `data`, `allowAllRelativesChart`, `allowPrintAndDownload`, `eventHandlers`, etc.), preparing the component for rendering on the landing screen without dummy properties.
|
||||||
|
|
||||||
#### Phase 2: WebMCP Bridge Extraction
|
#### Phase 2: WebMCP Bridge Extraction
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const config: Config.InitialOptions = {
|
|||||||
"d3-selection": "<rootDir>/node_modules/d3-selection/dist/d3-selection.js",
|
"d3-selection": "<rootDir>/node_modules/d3-selection/dist/d3-selection.js",
|
||||||
"d3-timer": "<rootDir>/node_modules/d3-timer/dist/d3-timer.js",
|
"d3-timer": "<rootDir>/node_modules/d3-timer/dist/d3-timer.js",
|
||||||
"d3-transition": "<rootDir>/node_modules/d3-transition/dist/d3-transition.js",
|
"d3-transition": "<rootDir>/node_modules/d3-transition/dist/d3-transition.js",
|
||||||
|
"d3-drag": "<rootDir>/node_modules/d3-drag/dist/d3-drag.js",
|
||||||
|
"d3-zoom": "<rootDir>/node_modules/d3-zoom/dist/d3-zoom.js",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
158
src/app.tsx
158
src/app.tsx
@@ -1,4 +1,3 @@
|
|||||||
import * as H from 'history';
|
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
import {useIntl} from 'react-intl';
|
||||||
@@ -48,7 +47,6 @@ import {Intro} from './intro';
|
|||||||
import {GoogleAuthModal} from './menu/google_auth_modal';
|
import {GoogleAuthModal} from './menu/google_auth_modal';
|
||||||
import {TopBar} from './menu/top_bar';
|
import {TopBar} from './menu/top_bar';
|
||||||
import {
|
import {
|
||||||
argsToConfig,
|
|
||||||
Config,
|
Config,
|
||||||
configToArgs,
|
configToArgs,
|
||||||
DEFALUT_CONFIG,
|
DEFALUT_CONFIG,
|
||||||
@@ -60,29 +58,14 @@ import {analyticsEvent} from './util/analytics';
|
|||||||
import {TopolaError} from './util/error';
|
import {TopolaError} from './util/error';
|
||||||
import {getI18nMessage} from './util/error_i18n';
|
import {getI18nMessage} from './util/error_i18n';
|
||||||
import {idToIndiMap, TopolaData} from './util/gedcom_util';
|
import {idToIndiMap, TopolaData} from './util/gedcom_util';
|
||||||
|
import {
|
||||||
|
DataSourceSpec,
|
||||||
|
getArguments,
|
||||||
|
getStaticUrl,
|
||||||
|
getUrlForArgs,
|
||||||
|
} from './util/url_args';
|
||||||
import {WebMcpBridge} from './webmcp';
|
import {WebMcpBridge} from './webmcp';
|
||||||
|
|
||||||
/**
|
|
||||||
* Load GEDCOM URL from environment variable (Vite VITE_STATIC_URL or dynamically
|
|
||||||
* injected via a meta tag from Caddy server).
|
|
||||||
*
|
|
||||||
* If this static URL is provided, the viewer is switched to
|
|
||||||
* single-tree mode without the option to load other data.
|
|
||||||
*/
|
|
||||||
function getStaticUrl(): string | undefined {
|
|
||||||
const envUrl = import.meta.env.VITE_STATIC_URL;
|
|
||||||
if (envUrl) return envUrl;
|
|
||||||
|
|
||||||
const metaTag = document.querySelector('meta[name="topola-static-url"]');
|
|
||||||
const metaUrl = metaTag?.getAttribute('content');
|
|
||||||
// Safely ignore if it is empty, the raw caddy template expression, or Vite's raw template placeholder
|
|
||||||
if (metaUrl && !metaUrl.startsWith('__') && !metaUrl.includes('{{ env')) {
|
|
||||||
return metaUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const staticUrl = getStaticUrl();
|
const staticUrl = getStaticUrl();
|
||||||
|
|
||||||
enum AppState {
|
enum AppState {
|
||||||
@@ -93,124 +76,6 @@ enum AppState {
|
|||||||
LOADING_MORE,
|
LOADING_MORE,
|
||||||
}
|
}
|
||||||
|
|
||||||
type DataSourceSpec =
|
|
||||||
| UrlSourceSpec
|
|
||||||
| UploadSourceSpec
|
|
||||||
| WikiTreeSourceSpec
|
|
||||||
| EmbeddedSourceSpec
|
|
||||||
| GoogleDriveSourceSpec;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Arguments passed to the application, primarily through URL parameters.
|
|
||||||
* Non-optional arguments get populated with default values.
|
|
||||||
*/
|
|
||||||
interface Arguments {
|
|
||||||
sourceSpec?: DataSourceSpec;
|
|
||||||
selection?: IndiInfo;
|
|
||||||
chartType: ChartType;
|
|
||||||
standalone: boolean;
|
|
||||||
showWikiTreeMenus: boolean;
|
|
||||||
freezeAnimation: boolean;
|
|
||||||
showSidePanel: boolean;
|
|
||||||
config: Config;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParamFromSearch(name: string, search: queryString.ParsedQuery) {
|
|
||||||
const value = search[name];
|
|
||||||
return typeof value === 'string' ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve arguments passed into the application through the URL and uploaded
|
|
||||||
* data.
|
|
||||||
*/
|
|
||||||
function getArguments(location: H.Location): Arguments {
|
|
||||||
const search = queryString.parse(location.search);
|
|
||||||
const getParam = (name: string) => getParamFromSearch(name, search);
|
|
||||||
|
|
||||||
const view = getParam('view');
|
|
||||||
const chartTypes = new Map<string | undefined, ChartType>([
|
|
||||||
['relatives', ChartType.Relatives],
|
|
||||||
['fancy', ChartType.Fancy],
|
|
||||||
['donatso', ChartType.Donatso],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hash = getParam('file');
|
|
||||||
const url = getParam('url');
|
|
||||||
const embedded = getParam('embedded') === 'true'; // False by default.
|
|
||||||
let sourceSpec: DataSourceSpec | undefined = undefined;
|
|
||||||
if (staticUrl) {
|
|
||||||
sourceSpec = {
|
|
||||||
source: DataSourceEnum.GEDCOM_URL,
|
|
||||||
url: staticUrl,
|
|
||||||
handleCors: false,
|
|
||||||
};
|
|
||||||
} else if (getParam('source') === 'wikitree') {
|
|
||||||
const windowSearch = queryString.parse(window.location.search);
|
|
||||||
sourceSpec = {
|
|
||||||
source: DataSourceEnum.WIKITREE,
|
|
||||||
authcode:
|
|
||||||
getParam('authcode') || getParamFromSearch('authcode', windowSearch),
|
|
||||||
};
|
|
||||||
} else if (getParam('source') === 'google-drive') {
|
|
||||||
const fileId = getParam('fileId');
|
|
||||||
if (fileId) {
|
|
||||||
sourceSpec = {
|
|
||||||
source: DataSourceEnum.GOOGLE_DRIVE,
|
|
||||||
fileId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if (hash) {
|
|
||||||
sourceSpec = {
|
|
||||||
source: DataSourceEnum.UPLOADED,
|
|
||||||
hash,
|
|
||||||
};
|
|
||||||
} else if (url) {
|
|
||||||
sourceSpec = {
|
|
||||||
source: DataSourceEnum.GEDCOM_URL,
|
|
||||||
url,
|
|
||||||
handleCors: getParam('handleCors') !== 'false', // True by default.
|
|
||||||
};
|
|
||||||
} else if (embedded) {
|
|
||||||
sourceSpec = {source: DataSourceEnum.EMBEDDED};
|
|
||||||
}
|
|
||||||
|
|
||||||
const indi = getParam('indi');
|
|
||||||
const parsedGen = Number(getParam('gen'));
|
|
||||||
const selection = indi
|
|
||||||
? {id: indi, generation: !isNaN(parsedGen) ? parsedGen : 0}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether the side panel should be shown taking into account the
|
|
||||||
* URL parameter and the viewport size.
|
|
||||||
*
|
|
||||||
* On mobile devices (max-width: 767px), the side panel is hidden by default.
|
|
||||||
* On tablet and desktop, the side panel is shown by default.
|
|
||||||
*/
|
|
||||||
function getShowSidePanel() {
|
|
||||||
if (window.matchMedia('(max-width: 767px)').matches) {
|
|
||||||
// On mobile, hide the side panel by default.
|
|
||||||
return getParam('sidePanel') === 'true';
|
|
||||||
}
|
|
||||||
// On tablet and desktop, show the side panel by default.
|
|
||||||
return getParam('sidePanel') !== 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sourceSpec,
|
|
||||||
selection,
|
|
||||||
// Hourglass is the default view.
|
|
||||||
chartType: chartTypes.get(view) || ChartType.Hourglass,
|
|
||||||
|
|
||||||
showSidePanel: getShowSidePanel(),
|
|
||||||
standalone: getParam('standalone') !== 'false' && !embedded && !staticUrl,
|
|
||||||
showWikiTreeMenus: getParam('showWikiTreeMenus') !== 'false', // True by default.
|
|
||||||
freezeAnimation: getParam('freeze') === 'true', // False by default
|
|
||||||
config: argsToConfig(search),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
/** State of the application. */
|
/** State of the application. */
|
||||||
const [state, setState] = useState<AppState>(AppState.INITIAL);
|
const [state, setState] = useState<AppState>(AppState.INITIAL);
|
||||||
@@ -620,13 +485,10 @@ export function App() {
|
|||||||
});
|
});
|
||||||
}, [mcpBridge, location]);
|
}, [mcpBridge, location]);
|
||||||
|
|
||||||
function updateUrl(args: queryString.ParsedQuery<string>) {
|
function updateUrl(
|
||||||
const search = queryString.parse(location.search);
|
args: Record<string, string | (string | null)[] | null | undefined>,
|
||||||
for (const key in args) {
|
) {
|
||||||
search[key] = args[key];
|
navigate(getUrlForArgs(location, args));
|
||||||
}
|
|
||||||
location.search = queryString.stringify(search);
|
|
||||||
navigate(location);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
315
src/util/url_args.spec.ts
Normal file
315
src/util/url_args.spec.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals';
|
||||||
|
import * as H from 'history';
|
||||||
|
import {ChartType} from '../chart';
|
||||||
|
import {DataSourceEnum} from '../datasource/data_source';
|
||||||
|
import {ChartColors, Ids, PlaceDisplay, Sex} from '../sidepanel/config/config';
|
||||||
|
import {
|
||||||
|
getArguments,
|
||||||
|
getParamFromSearch,
|
||||||
|
getStaticUrl,
|
||||||
|
getUrlForArgs,
|
||||||
|
} from './url_args';
|
||||||
|
|
||||||
|
describe('url_args', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
let documentMock: {
|
||||||
|
querySelector: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = {...originalEnv};
|
||||||
|
// Mock document globally
|
||||||
|
documentMock = {
|
||||||
|
querySelector: jest.fn().mockReturnValue(null),
|
||||||
|
};
|
||||||
|
Object.defineProperty(global, 'document', {
|
||||||
|
value: documentMock,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
// Clean up document mock
|
||||||
|
Object.defineProperty(global, 'document', {
|
||||||
|
value: undefined,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStaticUrl', () => {
|
||||||
|
it('returns VITE_STATIC_URL if set', () => {
|
||||||
|
process.env.VITE_STATIC_URL = 'http://example.com/static.ged';
|
||||||
|
expect(getStaticUrl()).toBe('http://example.com/static.ged');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns meta tag url if meta tag is present and valid', () => {
|
||||||
|
const mockMeta = {
|
||||||
|
getAttribute: jest.fn().mockReturnValue('http://example.com/meta.ged'),
|
||||||
|
};
|
||||||
|
documentMock.querySelector.mockReturnValue(mockMeta);
|
||||||
|
|
||||||
|
expect(getStaticUrl()).toBe('http://example.com/meta.ged');
|
||||||
|
expect(documentMock.querySelector).toHaveBeenCalledWith(
|
||||||
|
'meta[name="topola-static-url"]',
|
||||||
|
);
|
||||||
|
expect(mockMeta.getAttribute).toHaveBeenCalledWith('content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores template placeholder in meta tag', () => {
|
||||||
|
const mockMeta = {
|
||||||
|
getAttribute: jest.fn().mockReturnValue('{{ env "STATIC_URL" }}'),
|
||||||
|
};
|
||||||
|
documentMock.querySelector.mockReturnValue(mockMeta);
|
||||||
|
|
||||||
|
expect(getStaticUrl()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores __ placeholder in meta tag', () => {
|
||||||
|
const mockMeta = {
|
||||||
|
getAttribute: jest.fn().mockReturnValue('__STATIC_URL_PLACEHOLDER__'),
|
||||||
|
};
|
||||||
|
documentMock.querySelector.mockReturnValue(mockMeta);
|
||||||
|
|
||||||
|
expect(getStaticUrl()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if neither VITE_STATIC_URL nor meta tag is present', () => {
|
||||||
|
expect(getStaticUrl()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getParamFromSearch', () => {
|
||||||
|
it('extracts query param value', () => {
|
||||||
|
expect(getParamFromSearch('test', {test: 'val'})).toBe('val');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if param is array or missing', () => {
|
||||||
|
expect(
|
||||||
|
getParamFromSearch('test', {test: ['val1', 'val2']}),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(getParamFromSearch('missing', {test: 'val'})).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getArguments', () => {
|
||||||
|
const createLocation = (search: string): H.Location => ({
|
||||||
|
pathname: '/view',
|
||||||
|
search,
|
||||||
|
hash: '',
|
||||||
|
state: null,
|
||||||
|
key: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns defaults for empty search', () => {
|
||||||
|
const args = getArguments(createLocation(''));
|
||||||
|
expect(args.sourceSpec).toBeUndefined();
|
||||||
|
expect(args.selection).toBeUndefined();
|
||||||
|
expect(args.detail).toBeUndefined();
|
||||||
|
expect(args.chartType).toBe(ChartType.Hourglass);
|
||||||
|
expect(args.standalone).toBe(true);
|
||||||
|
expect(args.showWikiTreeMenus).toBe(true);
|
||||||
|
expect(args.freezeAnimation).toBe(false);
|
||||||
|
expect(args.showSidePanel).toBe(true); // default on desktop
|
||||||
|
expect(args.config).toEqual({
|
||||||
|
color: ChartColors.COLOR_BY_GENERATION,
|
||||||
|
id: Ids.SHOW,
|
||||||
|
sex: Sex.SHOW,
|
||||||
|
place: PlaceDisplay.FULL,
|
||||||
|
placeCount: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses chart view types correctly', () => {
|
||||||
|
expect(getArguments(createLocation('?view=relatives')).chartType).toBe(
|
||||||
|
ChartType.Relatives,
|
||||||
|
);
|
||||||
|
expect(getArguments(createLocation('?view=fancy')).chartType).toBe(
|
||||||
|
ChartType.Fancy,
|
||||||
|
);
|
||||||
|
expect(getArguments(createLocation('?view=donatso')).chartType).toBe(
|
||||||
|
ChartType.Donatso,
|
||||||
|
);
|
||||||
|
expect(getArguments(createLocation('?view=unknown')).chartType).toBe(
|
||||||
|
ChartType.Hourglass,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses WikiTree source spec', () => {
|
||||||
|
const args = getArguments(
|
||||||
|
createLocation('?source=wikitree&authcode=123'),
|
||||||
|
);
|
||||||
|
expect(args.sourceSpec).toEqual({
|
||||||
|
source: DataSourceEnum.WIKITREE,
|
||||||
|
authcode: '123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses Google Drive source spec', () => {
|
||||||
|
const args = getArguments(
|
||||||
|
createLocation('?source=google-drive&fileId=abc'),
|
||||||
|
);
|
||||||
|
expect(args.sourceSpec).toEqual({
|
||||||
|
source: DataSourceEnum.GOOGLE_DRIVE,
|
||||||
|
fileId: 'abc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses Uploaded source spec', () => {
|
||||||
|
const args = getArguments(createLocation('?file=hash123'));
|
||||||
|
expect(args.sourceSpec).toEqual({
|
||||||
|
source: DataSourceEnum.UPLOADED,
|
||||||
|
hash: 'hash123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses GEDCOM URL source spec', () => {
|
||||||
|
const args = getArguments(
|
||||||
|
createLocation('?url=http://example.com/tree.ged'),
|
||||||
|
);
|
||||||
|
expect(args.sourceSpec).toEqual({
|
||||||
|
source: DataSourceEnum.GEDCOM_URL,
|
||||||
|
url: 'http://example.com/tree.ged',
|
||||||
|
handleCors: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses GEDCOM URL source spec with handleCors false', () => {
|
||||||
|
const args = getArguments(
|
||||||
|
createLocation('?url=http://example.com/tree.ged&handleCors=false'),
|
||||||
|
);
|
||||||
|
expect(args.sourceSpec).toEqual({
|
||||||
|
source: DataSourceEnum.GEDCOM_URL,
|
||||||
|
url: 'http://example.com/tree.ged',
|
||||||
|
handleCors: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses Embedded source spec', () => {
|
||||||
|
const args = getArguments(createLocation('?embedded=true'));
|
||||||
|
expect(args.sourceSpec).toEqual({
|
||||||
|
source: DataSourceEnum.EMBEDDED,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers staticUrl over other source specs', () => {
|
||||||
|
process.env.VITE_STATIC_URL = 'http://example.com/static.ged';
|
||||||
|
const args = getArguments(
|
||||||
|
createLocation('?embedded=true&url=http://other.com'),
|
||||||
|
);
|
||||||
|
expect(args.sourceSpec).toEqual({
|
||||||
|
source: DataSourceEnum.GEDCOM_URL,
|
||||||
|
url: 'http://example.com/static.ged',
|
||||||
|
handleCors: false,
|
||||||
|
});
|
||||||
|
expect(args.standalone).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses selection correctly', () => {
|
||||||
|
const args = getArguments(createLocation('?indi=I123&gen=4'));
|
||||||
|
expect(args.selection).toEqual({
|
||||||
|
id: 'I123',
|
||||||
|
generation: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults selection generation to 0 if missing or invalid', () => {
|
||||||
|
const args1 = getArguments(createLocation('?indi=I123'));
|
||||||
|
expect(args1.selection).toEqual({
|
||||||
|
id: 'I123',
|
||||||
|
generation: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const args2 = getArguments(createLocation('?indi=I123&gen=abc'));
|
||||||
|
expect(args2.selection).toEqual({
|
||||||
|
id: 'I123',
|
||||||
|
generation: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses detail parameter', () => {
|
||||||
|
const args = getArguments(createLocation('?detail=I456'));
|
||||||
|
expect(args.detail).toBe('I456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses showSidePanel setting', () => {
|
||||||
|
// Mock window.matchMedia for desktop
|
||||||
|
Object.defineProperty(global, 'window', {
|
||||||
|
value: {
|
||||||
|
matchMedia: jest.fn().mockReturnValue({matches: false}),
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
expect(getArguments(createLocation('')).showSidePanel).toBe(true);
|
||||||
|
expect(
|
||||||
|
getArguments(createLocation('?sidePanel=false')).showSidePanel,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// Mock window.matchMedia for mobile
|
||||||
|
Object.defineProperty(global, 'window', {
|
||||||
|
value: {
|
||||||
|
matchMedia: jest.fn().mockReturnValue({matches: true}),
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
expect(getArguments(createLocation('')).showSidePanel).toBe(false);
|
||||||
|
expect(
|
||||||
|
getArguments(createLocation('?sidePanel=true')).showSidePanel,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses boolean settings (standalone, showWikiTreeMenus, freeze)', () => {
|
||||||
|
const args = getArguments(
|
||||||
|
createLocation('?standalone=false&showWikiTreeMenus=false&freeze=true'),
|
||||||
|
);
|
||||||
|
expect(args.standalone).toBe(false);
|
||||||
|
expect(args.showWikiTreeMenus).toBe(false);
|
||||||
|
expect(args.freezeAnimation).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses config object from query parameters', () => {
|
||||||
|
const args = getArguments(createLocation('?c=s&i=h&s=h&p=s&pn=5'));
|
||||||
|
expect(args.config).toEqual({
|
||||||
|
color: ChartColors.COLOR_BY_SEX,
|
||||||
|
id: Ids.HIDE,
|
||||||
|
sex: Sex.HIDE,
|
||||||
|
place: PlaceDisplay.SHORT,
|
||||||
|
placeCount: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUrlForArgs', () => {
|
||||||
|
const createLocation = (search: string): H.Location => ({
|
||||||
|
pathname: '/view',
|
||||||
|
search,
|
||||||
|
hash: '#hash-val',
|
||||||
|
state: null,
|
||||||
|
key: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates query parameter value and preserves pathname/hash', () => {
|
||||||
|
const loc = createLocation('?param1=old¶m2=keep');
|
||||||
|
const updated = getUrlForArgs(loc, {param1: 'new', param3: 'added'});
|
||||||
|
expect(updated).toEqual({
|
||||||
|
pathname: '/view',
|
||||||
|
search: '?param1=new¶m2=keep¶m3=added',
|
||||||
|
hash: '#hash-val',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes query parameters set to null or undefined', () => {
|
||||||
|
const loc = createLocation('?param1=val1¶m2=val2');
|
||||||
|
const updated = getUrlForArgs(loc, {param1: null, param2: undefined});
|
||||||
|
expect(updated).toEqual({
|
||||||
|
pathname: '/view',
|
||||||
|
search: '',
|
||||||
|
hash: '#hash-val',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
231
src/util/url_args.ts
Normal file
231
src/util/url_args.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import * as H from 'history';
|
||||||
|
import type {ParsedQuery} from 'query-string';
|
||||||
|
import {IndiInfo} from 'topola';
|
||||||
|
import {ChartType} from '../chart';
|
||||||
|
import {DataSourceEnum} from '../datasource/data_source';
|
||||||
|
import {EmbeddedSourceSpec} from '../datasource/embedded';
|
||||||
|
import {GoogleDriveSourceSpec} from '../datasource/google_drive';
|
||||||
|
import {UploadSourceSpec, UrlSourceSpec} from '../datasource/load_data';
|
||||||
|
import {WikiTreeSourceSpec} from '../datasource/wikitree';
|
||||||
|
import {argsToConfig, Config} from '../sidepanel/config/config';
|
||||||
|
|
||||||
|
export type DataSourceSpec =
|
||||||
|
| UrlSourceSpec
|
||||||
|
| UploadSourceSpec
|
||||||
|
| WikiTreeSourceSpec
|
||||||
|
| EmbeddedSourceSpec
|
||||||
|
| GoogleDriveSourceSpec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arguments passed to the application, primarily through URL parameters.
|
||||||
|
* Non-optional arguments get populated with default values.
|
||||||
|
*/
|
||||||
|
export interface Arguments {
|
||||||
|
sourceSpec?: DataSourceSpec;
|
||||||
|
selection?: IndiInfo;
|
||||||
|
detail?: string;
|
||||||
|
chartType: ChartType;
|
||||||
|
standalone: boolean;
|
||||||
|
showWikiTreeMenus: boolean;
|
||||||
|
freezeAnimation: boolean;
|
||||||
|
showSidePanel: boolean;
|
||||||
|
config: Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a query string using URLSearchParams.
|
||||||
|
*/
|
||||||
|
function parseQuery(search: string): ParsedQuery {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const result: ParsedQuery = {};
|
||||||
|
params.forEach((value, key) => {
|
||||||
|
const existing = result[key];
|
||||||
|
if (existing !== undefined) {
|
||||||
|
if (Array.isArray(existing)) {
|
||||||
|
existing.push(value);
|
||||||
|
} else {
|
||||||
|
result[key] = [existing as string, value];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stringifies a query object using URLSearchParams.
|
||||||
|
*/
|
||||||
|
function stringifyQuery(query: ParsedQuery): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const key in query) {
|
||||||
|
const val = query[key];
|
||||||
|
if (val !== undefined && val !== null) {
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
val.forEach((v) => {
|
||||||
|
if (v !== null && v !== undefined) {
|
||||||
|
params.append(key, v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
params.set(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const str = params.toString();
|
||||||
|
return str ? `?${str}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load GEDCOM URL from environment variable (Vite VITE_STATIC_URL or dynamically
|
||||||
|
* injected via a meta tag from Caddy server).
|
||||||
|
*
|
||||||
|
* If this static URL is provided, the viewer is switched to
|
||||||
|
* single-tree mode without the option to load other data.
|
||||||
|
*/
|
||||||
|
export function getStaticUrl(): string | undefined {
|
||||||
|
const envUrl = import.meta.env.VITE_STATIC_URL;
|
||||||
|
if (envUrl) return envUrl;
|
||||||
|
|
||||||
|
const metaTag = document.querySelector('meta[name="topola-static-url"]');
|
||||||
|
const metaUrl = metaTag?.getAttribute('content');
|
||||||
|
// Safely ignore if it is empty, the raw caddy template expression, or Vite's raw template placeholder
|
||||||
|
if (metaUrl && !metaUrl.startsWith('__') && !metaUrl.includes('{{ env')) {
|
||||||
|
return metaUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParamFromSearch(name: string, search: ParsedQuery) {
|
||||||
|
const value = search[name];
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve arguments passed into the application through the URL and uploaded
|
||||||
|
* data.
|
||||||
|
*/
|
||||||
|
export function getArguments(location: H.Location): Arguments {
|
||||||
|
const search = parseQuery(location.search);
|
||||||
|
const getParam = (name: string) => getParamFromSearch(name, search);
|
||||||
|
|
||||||
|
const view = getParam('view');
|
||||||
|
const chartTypes = new Map<string | undefined, ChartType>([
|
||||||
|
['relatives', ChartType.Relatives],
|
||||||
|
['fancy', ChartType.Fancy],
|
||||||
|
['donatso', ChartType.Donatso],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hash = getParam('file');
|
||||||
|
const url = getParam('url');
|
||||||
|
const embedded = getParam('embedded') === 'true'; // False by default.
|
||||||
|
const staticUrl = getStaticUrl();
|
||||||
|
let sourceSpec: DataSourceSpec | undefined = undefined;
|
||||||
|
if (staticUrl) {
|
||||||
|
sourceSpec = {
|
||||||
|
source: DataSourceEnum.GEDCOM_URL,
|
||||||
|
url: staticUrl,
|
||||||
|
handleCors: false,
|
||||||
|
};
|
||||||
|
} else if (getParam('source') === 'wikitree') {
|
||||||
|
const windowSearch =
|
||||||
|
typeof window !== 'undefined' ? parseQuery(window.location.search) : {};
|
||||||
|
sourceSpec = {
|
||||||
|
source: DataSourceEnum.WIKITREE,
|
||||||
|
authcode:
|
||||||
|
getParam('authcode') || getParamFromSearch('authcode', windowSearch),
|
||||||
|
};
|
||||||
|
} else if (getParam('source') === 'google-drive') {
|
||||||
|
const fileId = getParam('fileId');
|
||||||
|
if (fileId) {
|
||||||
|
sourceSpec = {
|
||||||
|
source: DataSourceEnum.GOOGLE_DRIVE,
|
||||||
|
fileId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (hash) {
|
||||||
|
sourceSpec = {
|
||||||
|
source: DataSourceEnum.UPLOADED,
|
||||||
|
hash,
|
||||||
|
};
|
||||||
|
} else if (url) {
|
||||||
|
sourceSpec = {
|
||||||
|
source: DataSourceEnum.GEDCOM_URL,
|
||||||
|
url,
|
||||||
|
handleCors: getParam('handleCors') !== 'false', // True by default.
|
||||||
|
};
|
||||||
|
} else if (embedded) {
|
||||||
|
sourceSpec = {source: DataSourceEnum.EMBEDDED};
|
||||||
|
}
|
||||||
|
|
||||||
|
const indi = getParam('indi');
|
||||||
|
const parsedGen = Number(getParam('gen'));
|
||||||
|
const selection = indi
|
||||||
|
? {id: indi, generation: !isNaN(parsedGen) ? parsedGen : 0}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const detail = getParam('detail');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the side panel should be shown taking into account the
|
||||||
|
* URL parameter and the viewport size.
|
||||||
|
*
|
||||||
|
* On mobile devices (max-width: 767px), the side panel is hidden by default.
|
||||||
|
* On tablet and desktop, the side panel is shown by default.
|
||||||
|
*/
|
||||||
|
function getShowSidePanel() {
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia &&
|
||||||
|
window.matchMedia('(max-width: 767px)').matches
|
||||||
|
) {
|
||||||
|
// On mobile, hide the side panel by default.
|
||||||
|
return getParam('sidePanel') === 'true';
|
||||||
|
}
|
||||||
|
// On tablet and desktop, show the side panel by default.
|
||||||
|
return getParam('sidePanel') !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceSpec,
|
||||||
|
selection,
|
||||||
|
detail,
|
||||||
|
// Hourglass is the default view.
|
||||||
|
chartType: chartTypes.get(view) || ChartType.Hourglass,
|
||||||
|
|
||||||
|
showSidePanel: getShowSidePanel(),
|
||||||
|
standalone: getParam('standalone') !== 'false' && !embedded && !staticUrl,
|
||||||
|
showWikiTreeMenus: getParam('showWikiTreeMenus') !== 'false', // True by default.
|
||||||
|
freezeAnimation: getParam('freeze') === 'true', // False by default
|
||||||
|
config: argsToConfig(search),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a path/query object suitable for passing to React Router's `navigate` function
|
||||||
|
* with new values added or removed from the current URL.
|
||||||
|
*/
|
||||||
|
export function getUrlForArgs(
|
||||||
|
location: H.Location,
|
||||||
|
newArgs: Record<string, string | (string | null)[] | null | undefined>,
|
||||||
|
): {pathname: string; search: string; hash: string} {
|
||||||
|
const search = parseQuery(location.search);
|
||||||
|
for (const key in newArgs) {
|
||||||
|
const val = newArgs[key];
|
||||||
|
if (val === undefined || val === null) {
|
||||||
|
delete search[key];
|
||||||
|
} else if (Array.isArray(val)) {
|
||||||
|
search[key] = val.filter(
|
||||||
|
(v): v is string => v !== null && v !== undefined,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
search[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: stringifyQuery(search),
|
||||||
|
hash: location.hash,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user