Refactored app.tsx to split the componentDidUpdate function

This commit is contained in:
Przemek Wiech
2020-03-05 21:50:54 +01:00
parent 483ec0d7e7
commit 768e6d7111

View File

@@ -1,3 +1,4 @@
import * as H from 'history';
import * as queryString from 'query-string'; import * as queryString from 'query-string';
import * as React from 'react'; import * as React from 'react';
import {analyticsEvent} from './analytics'; import {analyticsEvent} from './analytics';
@@ -10,9 +11,9 @@ import {IndiInfo} from 'topola';
import {intlShape} from 'react-intl'; import {intlShape} from 'react-intl';
import {Intro} from './intro'; import {Intro} from './intro';
import {Loader, Message, Portal, Responsive} from 'semantic-ui-react'; import {Loader, Message, Portal, Responsive} from 'semantic-ui-react';
import {loadWikiTree} from './wikitree';
import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom'; import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom';
import {TopBar} from './top_bar'; import {TopBar} from './top_bar';
import {loadWikiTree} from './wikitree';
/** Shows an error message in the middle of the screen. */ /** Shows an error message in the middle of the screen. */
function ErrorMessage(props: {message?: string}) { function ErrorMessage(props: {message?: string}) {
@@ -74,6 +75,158 @@ interface GedcomMessage extends EmbeddedMessage {
gedcom?: string; 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. */ /** Returs true if the changes object has values that are different than those in state. */
function hasUpdatedValues<T>(state: T, changes: Partial<T> | undefined) { function hasUpdatedValues<T>(state: T, changes: Partial<T> | undefined) {
if (!changes) { if (!changes) {
@@ -107,8 +260,8 @@ interface State {
chartType: ChartType; chartType: ChartType;
/** Whether to show the error popup. */ /** Whether to show the error popup. */
showErrorPopup: boolean; showErrorPopup: boolean;
/** True if data is loaded from WikiTree. */ /** Source of the data. */
wikiTreeSource: boolean; source?: DataSourceEnum;
loadingMore?: boolean; loadingMore?: boolean;
} }
@@ -119,7 +272,6 @@ export class App extends React.Component<RouteComponentProps, {}> {
standalone: true, standalone: true,
chartType: ChartType.Hourglass, chartType: ChartType.Hourglass,
showErrorPopup: false, showErrorPopup: false,
wikiTreeSource: false,
}; };
chartRef: Chart | null = null; chartRef: Chart | null = null;
@@ -128,23 +280,6 @@ export class App extends React.Component<RouteComponentProps, {}> {
intl: intlShape, intl: intlShape,
}; };
private isNewData(
hash: string | undefined,
url: string | undefined,
gedcom: string | undefined,
source: string | undefined,
): boolean {
return (
!!(hash && hash !== this.state.hash) ||
!!(url && this.state.url !== url) ||
(!!gedcom && !this.state.loading && !this.state.data) ||
(source === 'wikitree' &&
!this.state.loading &&
!this.state.data &&
!this.state.error)
);
}
/** Sets the state with a new individual selection and chart type. */ /** Sets the state with a new individual selection and chart type. */
private updateDisplay( private updateDisplay(
selection: IndiInfo, selection: IndiInfo,
@@ -212,136 +347,97 @@ export class App extends React.Component<RouteComponentProps, {}> {
return; return;
} }
const search = queryString.parse(this.props.location.search); const args = getArguments(this.props.location);
const getParam = (name: string) => {
const value = search[name];
return typeof value === 'string' ? value : undefined;
};
const showSidePanel = getParam('sidePanel') !== 'false'; // True by default. if (args.embedded && !this.state.embedded) {
const embedded = getParam('embedded') === 'true'; // False by default.
if (embedded && !this.state.embedded) {
this.setState( this.setState(
Object.assign({}, this.state, { Object.assign({}, this.state, {
embedded: true, embedded: true,
standalone: false, standalone: false,
showSidePanel, showSidePanel: args.showSidePanel,
}), }),
); );
// Notify the parent window that we are ready. // Notify the parent window that we are ready.
window.parent.postMessage('ready', '*'); window.parent.postMessage('ready', '*');
window.addEventListener('message', (data) => this.onMessage(data.data)); window.addEventListener('message', (data) => this.onMessage(data.data));
} }
if (embedded) { if (args.embedded) {
// If the app is embedded, do not run the normal loading code. // If the app is embedded, do not run the normal loading code.
return; return;
} }
const url = getParam('url'); const dataSource = DATA_SOURCES.get(args.source!);
const indi = getParam('indi');
const parsedGen = Number(getParam('gen'));
const generation = !isNaN(parsedGen) ? parsedGen : undefined;
const hash = getParam('file');
const handleCors = getParam('handleCors') !== 'false'; // True by default.
const standalone = getParam('standalone') !== 'false'; // True by default.
const view = getParam('view');
const source = getParam('source');
const authcode = getParam('?authcode');
const chartTypes = new Map<string | undefined, ChartType>([ if (!dataSource) {
['relatives', ChartType.Relatives],
['fancy', ChartType.Fancy],
]);
// Hourglass is the default view.
const chartType = chartTypes.get(view) || ChartType.Hourglass;
const gedcom = this.props.location.state && this.props.location.state.data;
const images =
this.props.location.state && this.props.location.state.images;
if (!url && !hash && !source) {
this.props.history.replace({pathname: '/'}); this.props.history.replace({pathname: '/'});
} else if (this.isNewData(hash, url, gedcom, source)) { } 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 { try {
// Set loading state. const data = await dataSource.loadData(args);
this.setState(
Object.assign({}, this.state, {
data: undefined,
selection: undefined,
hash,
error: undefined,
loading: true,
url,
standalone,
chartType,
wikiTreeSource: source === 'wikitree',
}),
);
const data =
source === 'wikitree'
? await loadWikiTree(indi!, authcode)
: hash
? await loadGedcom(hash, gedcom, images)
: await loadFromUrl(url!, handleCors);
const software = getSoftware(data.gedcom.head);
if (source === 'wikitree') {
analyticsEvent('wikitree_loaded');
} else {
analyticsEvent(hash ? 'upload_file_loaded' : 'url_file_loaded', {
event_label: software,
event_value: (images && images.size) || 0,
});
}
// Set state with data. // Set state with data.
this.setState( this.setState(
Object.assign({}, this.state, { Object.assign({}, this.state, {
data, data,
hash, hash: args.hash,
selection: getSelection(data.chartData, indi, generation), selection: getSelection(data.chartData, args.indi, args.generation),
error: undefined, error: undefined,
loading: false, loading: false,
url, url: args.url,
showSidePanel, showSidePanel: args.showSidePanel,
standalone, standalone: args.standalone,
chartType, chartType: args.chartType,
wikiTreeSource: source === 'wikitree', source: args.source,
}), }),
); );
} catch (error) { } catch (error) {
analyticsEvent(hash ? 'upload_file_error' : 'url_file_error');
this.setError(error.message); this.setError(error.message);
} }
} else if (this.state.data && this.state.selection) { } else if (this.state.data && this.state.selection) {
// Update selection if it has changed in the URL. // Update selection if it has changed in the URL.
const selection = getSelection( const selection = getSelection(
this.state.data.chartData, this.state.data.chartData,
indi, args.indi,
generation, args.generation,
); );
const loadMoreFromWikitree = const loadMoreFromWikitree =
source === 'wikitree' && args.source === DataSourceEnum.WIKITREE &&
(!this.state.selection || this.state.selection.id !== selection.id); (!this.state.selection || this.state.selection.id !== selection.id);
this.updateDisplay(selection, { this.updateDisplay(selection, {
chartType, chartType: args.chartType,
loadingMore: loadMoreFromWikitree || undefined, loadingMore: loadMoreFromWikitree || undefined,
}); });
if (loadMoreFromWikitree) { if (loadMoreFromWikitree) {
const data = await loadWikiTree(indi!); const data = await loadWikiTree(args.indi!);
this.setState( this.setState(
Object.assign({}, this.state, { Object.assign({}, this.state, {
data, data,
hash, hash: args.hash,
selection: getSelection(data.chartData, indi, generation), selection: getSelection(data.chartData, args.indi, args.generation),
error: undefined, error: undefined,
loading: false, loading: false,
url, url: args.url,
showSidePanel, showSidePanel: args.showSidePanel,
standalone, standalone: args.standalone,
chartType, chartType: args.chartType,
wikiTreeSource: source === 'wikitree', source: args.source,
loadingMore: false, loadingMore: false,
}), }),
); );
@@ -471,7 +567,9 @@ export class App extends React.Component<RouteComponentProps, {}> {
<TopBar <TopBar
{...props} {...props}
gedcom={this.state.data && this.state.data.gedcom} gedcom={this.state.data && this.state.data.gedcom}
allowAllRelativesChart={!this.state.wikiTreeSource} allowAllRelativesChart={
this.state.source !== DataSourceEnum.WIKITREE
}
showingChart={ showingChart={
!!( !!(
this.props.history.location.pathname === '/view' && this.props.history.location.pathname === '/view' &&
@@ -487,7 +585,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
onDownloadPng: this.onDownloadPng, onDownloadPng: this.onDownloadPng,
onDownloadSvg: this.onDownloadSvg, onDownloadSvg: this.onDownloadSvg,
}} }}
showWikiTreeLogin={this.state.wikiTreeSource} showWikiTreeLogin={this.state.source === DataSourceEnum.WIKITREE}
/> />
)} )}
/> />