Files
topola-viewer/src/app.tsx

601 lines
17 KiB
TypeScript

import * as H from 'history';
import * as queryString from 'query-string';
import * as React from 'react';
import {analyticsEvent} from './analytics';
import {Chart, ChartType} from './chart';
import {Details} from './details';
import {FormattedMessage} from 'react-intl';
import {getSelection, loadFromUrl, loadGedcom} from './load_data';
import {getSoftware, TopolaData} from './gedcom_util';
import {IndiInfo} from 'topola';
import {intlShape} from 'react-intl';
import {Intro} from './intro';
import {Loader, Message, Portal, Responsive} from 'semantic-ui-react';
import {loadWikiTree} from './wikitree';
import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom';
import {TopBar} from './top_bar';
/** Shows an error message in the middle of the screen. */
function ErrorMessage(props: {message?: string}) {
return (
<Message negative className="error">
<Message.Header>
<FormattedMessage
id="error.failed_to_load_file"
defaultMessage={'Failed to load file'}
/>
</Message.Header>
<p>{props.message}</p>
</Message>
);
}
interface ErrorPopupProps {
message?: string;
open: boolean;
onDismiss: () => void;
}
/**
* Shows a dismissable error message in the bottom left corner of the screen.
*/
function ErrorPopup(props: ErrorPopupProps) {
return (
<Portal open={props.open} onClose={props.onDismiss}>
<Message negative className="errorPopup" onDismiss={props.onDismiss}>
<Message.Header>
<FormattedMessage id="error.error" defaultMessage={'Error'} />
</Message.Header>
<p>{props.message}</p>
</Message>
</Portal>
);
}
/**
* Message types used in embedded mode.
* When the parent is ready to receive messages, it sends PARENT_READY.
* When the child (this app) is ready to receive messages, it sends READY.
* When the child receives PARENT_READY, it sends READY.
* When the parent receives READY, it sends data in a GEDCOM message.
*/
enum EmbeddedMessageType {
GEDCOM = 'gedcom',
READY = 'ready',
PARENT_READY = 'parent_ready',
}
/** Message sent to parent or received from parent in embedded mode. */
interface EmbeddedMessage {
message: EmbeddedMessageType;
}
interface GedcomMessage extends EmbeddedMessage {
message: EmbeddedMessageType.GEDCOM;
gedcom?: string;
}
/** Interface encapsulating functions specific for a data source. */
interface DataSource {
/**
* Returns true if the application is now loading a completely new data set
* and the existing one should be wiped.
*/
isNewData(args: Arguments, state: State): boolean;
/** Loads data from the data source. */
loadData(args: Arguments): Promise<TopolaData>;
}
/** Files opened from the local computer. */
class UploadedDataSource implements DataSource {
isNewData(args: Arguments, state: State): boolean {
return (
args.hash !== state.hash ||
!!(args.gedcom && !state.loading && !state.data)
);
}
async loadData(args: Arguments): Promise<TopolaData> {
try {
const data = await loadGedcom(args.hash!, args.gedcom, args.images);
const software = getSoftware(data.gedcom.head);
analyticsEvent('upload_file_loaded', {
event_label: software,
event_value: (args.images && args.images.size) || 0,
});
return data;
} catch (error) {
analyticsEvent('upload_file_error');
throw error;
}
}
}
/** GEDCOM file loaded by pointing to a URL. */
class GedcomUrlDataSource implements DataSource {
isNewData(args: Arguments, state: State): boolean {
return args.url !== state.url;
}
async loadData(args: Arguments): Promise<TopolaData> {
try {
const data = await loadFromUrl(args.url!, args.handleCors);
const software = getSoftware(data.gedcom.head);
analyticsEvent('upload_file_loaded', {event_label: software});
return data;
} catch (error) {
analyticsEvent('url_file_error');
throw error;
}
}
}
/** Loading data from the WikiTree API. */
class WikiTreeDataSource implements DataSource {
isNewData(args: Arguments, state: State): boolean {
// WikiTree is always a single data source.
return false;
}
async loadData(args: Arguments): Promise<TopolaData> {
try {
const data = await loadWikiTree(args.indi!, args.authcode);
analyticsEvent('wikitree_loaded');
return data;
} catch (error) {
analyticsEvent('wikitree_error');
throw error;
}
}
}
/** Supported data sources. */
enum DataSourceEnum {
UPLOADED,
GEDCOM_URL,
WIKITREE,
}
/** Mapping from data source identifier to data source handler functions. */
const DATA_SOURCES = new Map([
[DataSourceEnum.UPLOADED, new UploadedDataSource()],
[DataSourceEnum.GEDCOM_URL, new GedcomUrlDataSource()],
[DataSourceEnum.WIKITREE, new WikiTreeDataSource()],
]);
/** Arguments passed to the application, primarily through URL parameters. */
interface Arguments {
showSidePanel: boolean;
embedded: boolean;
url?: string;
indi?: string;
generation?: number;
hash?: string;
handleCors: boolean;
standalone: boolean;
source?: DataSourceEnum;
authcode?: string;
chartType: ChartType;
gedcom?: string;
images?: Map<string, string>;
}
/**
* Retrieve arguments passed into the application through the URL and uploaded
* data.
*/
function getArguments(location: H.Location<any>): Arguments {
const search = queryString.parse(location.search);
const getParam = (name: string) => {
const value = search[name];
return typeof value === 'string' ? value : undefined;
};
const parsedGen = Number(getParam('gen'));
const view = getParam('view');
const chartTypes = new Map<string | undefined, ChartType>([
['relatives', ChartType.Relatives],
['fancy', ChartType.Fancy],
]);
const hash = getParam('file');
const url = getParam('url');
const source =
getParam('source') === 'wikitree'
? DataSourceEnum.WIKITREE
: hash
? DataSourceEnum.UPLOADED
: url
? DataSourceEnum.GEDCOM_URL
: undefined;
return {
showSidePanel: getParam('sidePanel') !== 'false', // True by default.
embedded: getParam('embedded') === 'true', // False by default.
url,
indi: getParam('indi'),
generation: !isNaN(parsedGen) ? parsedGen : undefined,
hash,
handleCors: getParam('handleCors') !== 'false', // True by default.
standalone: getParam('standalone') !== 'false', // True by default.
source,
authcode: getParam('?authcode'),
// Hourglass is the default view.
chartType: chartTypes.get(view) || ChartType.Hourglass,
gedcom: location.state && location.state.data,
images: location.state && location.state.images,
};
}
/** Returs true if the changes object has values that are different than those in state. */
function hasUpdatedValues<T>(state: T, changes: Partial<T> | undefined) {
if (!changes) {
return false;
}
return Object.entries(changes).some(
([key, value]) => value !== undefined && state[key] !== value,
);
}
interface State {
/** Loaded data. */
data?: TopolaData;
/** Selected individual. */
selection?: IndiInfo;
/** Hash of the GEDCOM contents. */
hash?: string;
/** Error to display. */
error?: string;
/** True if data is currently being loaded. */
loading: boolean;
/** URL of the data that is loaded or is being loaded. */
url?: string;
/** Whether the side panel is shown. */
showSidePanel?: boolean;
/** Whether the app is in embedded mode, i.e. embedded in an iframe. */
embedded: boolean;
/** Whether the app is in standalone mode, i.e. showing 'open file' menus. */
standalone: boolean;
/** Type of displayed chart. */
chartType: ChartType;
/** Whether to show the error popup. */
showErrorPopup: boolean;
/** Source of the data. */
source?: DataSourceEnum;
loadingMore?: boolean;
}
export class App extends React.Component<RouteComponentProps, {}> {
state: State = {
loading: false,
embedded: false,
standalone: true,
chartType: ChartType.Hourglass,
showErrorPopup: false,
};
chartRef: Chart | null = null;
/** Make intl appear in this.context. */
static contextTypes = {
intl: intlShape,
};
/** Sets the state with a new individual selection and chart type. */
private updateDisplay(
selection: IndiInfo,
otherStateChanges?: Partial<State>,
) {
if (
!this.state.selection ||
this.state.selection.id !== selection.id ||
this.state.selection!.generation !== selection.generation ||
hasUpdatedValues(this.state, otherStateChanges)
) {
this.setState(
Object.assign({}, this.state, {selection}, otherStateChanges),
);
}
}
/** Sets error message after data load failure. */
private setError(error: string) {
this.setState(
Object.assign({}, this.state, {
error: error,
loading: false,
}),
);
}
private async onMessage(message: EmbeddedMessage) {
if (message.message === EmbeddedMessageType.PARENT_READY) {
// Parent didn't receive the first 'ready' message, so we need to send it again.
window.parent.postMessage({message: EmbeddedMessageType.READY}, '*');
} else if (message.message === EmbeddedMessageType.GEDCOM) {
const gedcom = (message as GedcomMessage).gedcom;
if (!gedcom) {
return;
}
try {
const data = await loadGedcom('', gedcom);
const software = getSoftware(data.gedcom.head);
analyticsEvent('embedded_file_loaded', {
event_label: software,
});
// Set state with data.
this.setState(
Object.assign({}, this.state, {
data,
selection: getSelection(data.chartData),
error: undefined,
loading: false,
}),
);
} catch (error) {
analyticsEvent('embedded_file_error');
this.setError(error.message);
}
}
}
componentDidMount() {
this.componentDidUpdate();
}
async componentDidUpdate() {
if (this.props.location.pathname !== '/view') {
return;
}
const args = getArguments(this.props.location);
if (args.embedded && !this.state.embedded) {
this.setState(
Object.assign({}, this.state, {
embedded: true,
standalone: false,
showSidePanel: args.showSidePanel,
}),
);
// Notify the parent window that we are ready.
window.parent.postMessage('ready', '*');
window.addEventListener('message', (data) => this.onMessage(data.data));
}
if (args.embedded) {
// If the app is embedded, do not run the normal loading code.
return;
}
const dataSource = DATA_SOURCES.get(args.source!);
if (!dataSource) {
this.props.history.replace({pathname: '/'});
} else if (
(!this.state.loading && !this.state.data && !this.state.error) ||
args.source !== this.state.source ||
dataSource.isNewData(args, this.state)
) {
// Set loading state.
this.setState(
Object.assign({}, this.state, {
data: undefined,
selection: undefined,
hash: args.hash,
error: undefined,
loading: true,
url: args.url,
standalone: args.standalone,
chartType: args.chartType,
source: args.source,
}),
);
try {
const data = await dataSource.loadData(args);
// Set state with data.
this.setState(
Object.assign({}, this.state, {
data,
hash: args.hash,
selection: getSelection(data.chartData, args.indi, args.generation),
error: undefined,
loading: false,
url: args.url,
showSidePanel: args.showSidePanel,
standalone: args.standalone,
chartType: args.chartType,
source: args.source,
}),
);
} catch (error) {
this.setError(error.message);
}
} else if (this.state.data && this.state.selection) {
// Update selection if it has changed in the URL.
const selection = getSelection(
this.state.data.chartData,
args.indi,
args.generation,
);
const loadMoreFromWikitree =
args.source === DataSourceEnum.WIKITREE &&
(!this.state.selection || this.state.selection.id !== selection.id);
this.updateDisplay(selection, {
chartType: args.chartType,
loadingMore: loadMoreFromWikitree || undefined,
});
if (loadMoreFromWikitree) {
const data = await loadWikiTree(args.indi!);
this.setState(
Object.assign({}, this.state, {
data,
hash: args.hash,
selection: getSelection(data.chartData, args.indi, args.generation),
error: undefined,
loading: false,
url: args.url,
showSidePanel: args.showSidePanel,
standalone: args.standalone,
chartType: args.chartType,
source: args.source,
loadingMore: false,
}),
);
}
}
}
/**
* Called when the user clicks an individual box in the chart.
* Updates the browser URL.
*/
private onSelection = (selection: IndiInfo) => {
analyticsEvent('selection_changed');
if (this.state.embedded) {
// In embedded mode the URL doesn't change.
this.updateDisplay(selection);
return;
}
const location = this.props.location;
const search = queryString.parse(location.search);
search.indi = selection.id;
search.gen = String(selection.generation);
location.search = queryString.stringify(search);
this.props.history.push(location);
};
private onPrint = () => {
analyticsEvent('print');
this.chartRef && this.chartRef.print();
};
private showErrorPopup(message: string) {
this.setState(
Object.assign({}, this.state, {
showErrorPopup: true,
error: message,
}),
);
}
private onDownloadPdf = async () => {
analyticsEvent('download_pdf');
try {
this.chartRef && (await this.chartRef.downloadPdf());
} catch (e) {
this.showErrorPopup(
this.context.intl.formatMessage({
id: 'error.failed_pdf',
defaultMessage:
'Failed to generate PDF file.' +
' Please try with a smaller diagram or download an SVG file.',
}),
);
}
};
private onDownloadPng = async () => {
analyticsEvent('download_png');
try {
this.chartRef && (await this.chartRef.downloadPng());
} catch (e) {
this.showErrorPopup(
this.context.intl.formatMessage({
id: 'error.failed_png',
defaultMessage:
'Failed to generate PNG file.' +
' Please try with a smaller diagram or download an SVG file.',
}),
);
}
};
private onDownloadSvg = () => {
analyticsEvent('download_svg');
this.chartRef && this.chartRef.downloadSvg();
};
onDismissErrorPopup = () => {
this.setState(
Object.assign({}, this.state, {
showErrorPopup: false,
}),
);
};
private renderMainArea = () => {
if (this.state.data && this.state.selection) {
return (
<div id="content">
<ErrorPopup
open={this.state.showErrorPopup}
message={this.state.error}
onDismiss={this.onDismissErrorPopup}
/>
{this.state.loadingMore ? (
<Loader active inline size="small" />
) : null}
<Chart
data={this.state.data.chartData}
selection={this.state.selection}
chartType={this.state.chartType}
onSelection={this.onSelection}
ref={(ref) => (this.chartRef = ref)}
/>
{this.state.showSidePanel ? (
<Responsive minWidth={768} id="sidePanel">
<Details
gedcom={this.state.data.gedcom}
indi={this.state.selection.id}
/>
</Responsive>
) : null}
</div>
);
}
if (this.state.error) {
return <ErrorMessage message={this.state.error!} />;
}
return <Loader active size="large" />;
};
render() {
return (
<>
<Route
render={(props: RouteComponentProps) => (
<TopBar
{...props}
gedcom={this.state.data && this.state.data.gedcom}
allowAllRelativesChart={
this.state.source !== DataSourceEnum.WIKITREE
}
showingChart={
!!(
this.props.history.location.pathname === '/view' &&
this.state.data &&
this.state.selection
)
}
standalone={this.state.standalone}
eventHandlers={{
onSelection: this.onSelection,
onPrint: this.onPrint,
onDownloadPdf: this.onDownloadPdf,
onDownloadPng: this.onDownloadPng,
onDownloadSvg: this.onDownloadSvg,
}}
showWikiTreeLogin={this.state.source === DataSourceEnum.WIKITREE}
/>
)}
/>
<Switch>
<Route exact path="/" component={Intro} />
<Route exact path="/view" render={this.renderMainArea} />
<Redirect to={'/'} />
</Switch>
</>
);
}
}