Refactored AppComponent from class-based to functional

This commit is contained in:
Przemek Wiech
2021-11-04 15:18:26 +01:00
parent 15b79eaf39
commit 3059853807

View File

@@ -1,6 +1,5 @@
import * as H from 'history'; import * as H from 'history';
import * as queryString from 'query-string'; import * as queryString from 'query-string';
import React from 'react';
import {analyticsEvent} from './util/analytics'; import {analyticsEvent} from './util/analytics';
import {Changelog} from './changelog'; import {Changelog} from './changelog';
import {DataSourceEnum, SourceSelection} from './datasource/data_source'; import {DataSourceEnum, SourceSelection} from './datasource/data_source';
@@ -15,6 +14,7 @@ import {Media} from './util/media';
import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom'; import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom';
import {TopBar} from './menu/top_bar'; import {TopBar} from './menu/top_bar';
import {TopolaData} from './util/gedcom_util'; import {TopolaData} from './util/gedcom_util';
import {useEffect, useState} from 'react';
import { import {
Chart, Chart,
ChartType, ChartType,
@@ -184,270 +184,233 @@ function hasUpdatedValues<T>(state: T, changes: Partial<T> | undefined) {
); );
} }
interface State { function AppComponent(props: RouteComponentProps & WrappedComponentProps) {
/** State of the application. */ /** State of the application. */
state: AppState; const [state, setState] = useState<AppState>(AppState.INITIAL);
/** Loaded data. */ /** Loaded data. */
data?: TopolaData; const [data, setData] = useState<TopolaData>();
/** Selected individual. */ /** Selected individual. */
selection?: IndiInfo; const [selection, setSelection] = useState<IndiInfo>();
/** Error to display. */ /** Error to display. */
error?: string; const [error, setError] = useState<string>();
/** Whether the side panel is shown. */ /** Whether the side panel is shown. */
showSidePanel?: boolean; const [showSidePanel, setShowSidePanel] = useState(false);
/** Whether the app is in standalone mode, i.e. showing 'open file' menus. */ /** Whether the app is in standalone mode, i.e. showing 'open file' menus. */
standalone: boolean; const [standalone, setStandalone] = useState(true);
/** Type of displayed chart. */ /** Type of displayed chart. */
chartType: ChartType; const [chartType, setChartType] = useState<ChartType>(ChartType.Hourglass);
/** Whether to show the error popup. */ /** Whether to show the error popup. */
showErrorPopup: boolean; const [showErrorPopup, setShowErrorPopup] = useState(false);
/** Specification of the source of the data. */ /** Specification of the source of the data. */
sourceSpec?: DataSourceSpec; const [sourceSpec, setSourceSpec] = useState<DataSourceSpec>();
/** Freeze animations after initial chart render. */ /** Freeze animations after initial chart render. */
freezeAnimation?: boolean; const [freezeAnimation, setFreezeAnimation] = useState(false);
config: Config; const [config, setConfig] = useState(DEFALUT_CONFIG);
}
class AppComponent extends React.Component<
RouteComponentProps & WrappedComponentProps,
{}
> {
state: State = {
state: AppState.INITIAL,
standalone: true,
chartType: ChartType.Hourglass,
showErrorPopup: false,
config: DEFALUT_CONFIG,
};
/** Sets the state with a new individual selection and chart type. */ /** Sets the state with a new individual selection and chart type. */
private updateDisplay( function updateDisplay(newSelection: IndiInfo) {
selection: IndiInfo,
otherStateChanges?: Partial<State>,
) {
if ( if (
!this.state.selection || !selection ||
this.state.selection.id !== selection.id || selection.id !== newSelection.id ||
this.state.selection!.generation !== selection.generation || selection!.generation !== newSelection.generation
hasUpdatedValues(this.state, otherStateChanges)
) { ) {
this.setState( setSelection(newSelection);
Object.assign({}, this.state, {selection}, otherStateChanges),
);
} }
} }
/** Sets error message after data load failure. */ /** Sets error message after data load failure. */
private setError(error: string) { function setErrorMessage(message: string) {
this.setState( setError(message);
Object.assign({}, this.state, { setState(AppState.ERROR);
state: AppState.ERROR,
error,
}),
);
} }
componentDidMount() { const uploadedDataSource = new UploadedDataSource();
this.componentDidUpdate(); const gedcomUrlDataSource = new GedcomUrlDataSource();
} const wikiTreeDataSource = new WikiTreeDataSource(props.intl);
const embeddedDataSource = new EmbeddedDataSource();
private readonly uploadedDataSource = new UploadedDataSource(); function isNewData(newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) {
private readonly gedcomUrlDataSource = new GedcomUrlDataSource(); if (!sourceSpec || sourceSpec.source !== newSourceSpec.source) {
private readonly wikiTreeDataSource = new WikiTreeDataSource(this.props.intl);
private readonly embeddedDataSource = new EmbeddedDataSource();
private isNewData(sourceSpec: DataSourceSpec, selection?: IndiInfo) {
if (
!this.state.sourceSpec ||
this.state.sourceSpec.source !== sourceSpec.source
) {
// New data source means new data. // New data source means new data.
return true; return true;
} }
const newSource = {spec: sourceSpec, selection}; const newSource = {spec: newSourceSpec, selection: newSelection};
const oldSouce = { const oldSouce = {
spec: this.state.sourceSpec, spec: sourceSpec,
selection: this.state.selection, selection: selection,
}; };
switch (newSource.spec.source) { switch (newSource.spec.source) {
case DataSourceEnum.UPLOADED: case DataSourceEnum.UPLOADED:
return this.uploadedDataSource.isNewData( return uploadedDataSource.isNewData(
newSource as SourceSelection<UploadSourceSpec>, newSource as SourceSelection<UploadSourceSpec>,
oldSouce as SourceSelection<UploadSourceSpec>, oldSouce as SourceSelection<UploadSourceSpec>,
this.state.data, data,
); );
case DataSourceEnum.GEDCOM_URL: case DataSourceEnum.GEDCOM_URL:
return this.gedcomUrlDataSource.isNewData( return gedcomUrlDataSource.isNewData(
newSource as SourceSelection<UrlSourceSpec>, newSource as SourceSelection<UrlSourceSpec>,
oldSouce as SourceSelection<UrlSourceSpec>, oldSouce as SourceSelection<UrlSourceSpec>,
this.state.data, data,
); );
case DataSourceEnum.WIKITREE: case DataSourceEnum.WIKITREE:
return this.wikiTreeDataSource.isNewData( return wikiTreeDataSource.isNewData(
newSource as SourceSelection<WikiTreeSourceSpec>, newSource as SourceSelection<WikiTreeSourceSpec>,
oldSouce as SourceSelection<WikiTreeSourceSpec>, oldSouce as SourceSelection<WikiTreeSourceSpec>,
this.state.data, data,
); );
case DataSourceEnum.EMBEDDED: case DataSourceEnum.EMBEDDED:
return this.embeddedDataSource.isNewData( return embeddedDataSource.isNewData(
newSource as SourceSelection<EmbeddedSourceSpec>, newSource as SourceSelection<EmbeddedSourceSpec>,
oldSouce as SourceSelection<EmbeddedSourceSpec>, oldSouce as SourceSelection<EmbeddedSourceSpec>,
this.state.data, data,
); );
} }
} }
private loadData(sourceSpec: DataSourceSpec, selection?: IndiInfo) { function loadData(newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) {
switch (sourceSpec.source) { switch (newSourceSpec.source) {
case DataSourceEnum.UPLOADED: case DataSourceEnum.UPLOADED:
return this.uploadedDataSource.loadData({spec: sourceSpec, selection}); return uploadedDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.GEDCOM_URL: case DataSourceEnum.GEDCOM_URL:
return this.gedcomUrlDataSource.loadData({spec: sourceSpec, selection}); return gedcomUrlDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.WIKITREE: case DataSourceEnum.WIKITREE:
return this.wikiTreeDataSource.loadData({spec: sourceSpec, selection}); return wikiTreeDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.EMBEDDED: case DataSourceEnum.EMBEDDED:
return this.embeddedDataSource.loadData({spec: sourceSpec, selection}); return embeddedDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
} }
} }
async componentDidUpdate() { useEffect(() => {
if (this.props.location.pathname !== '/view') { (async () => {
if (this.state.state !== AppState.INITIAL) { if (props.location.pathname !== '/view') {
this.setState(Object.assign({}, this.state, {state: AppState.INITIAL})); if (state !== AppState.INITIAL) {
setState(AppState.INITIAL);
}
return;
} }
return;
}
const args = getArguments(this.props.location); const args = getArguments(props.location);
if (!args.sourceSpec) { if (!args.sourceSpec) {
this.props.history.replace({pathname: '/'}); props.history.replace({pathname: '/'});
} else if ( return;
this.state.state === AppState.INITIAL ||
this.isNewData(args.sourceSpec, args.selection)
) {
// Set loading state.
this.setState(
Object.assign({}, this.state, {
state: AppState.LOADING,
sourceSpec: args.sourceSpec,
selection: args.selection,
standalone: args.standalone,
chartType: args.chartType,
config: args.config,
}),
);
try {
const data = await this.loadData(args.sourceSpec, args.selection);
// Set state with data.
this.setState(
Object.assign({}, this.state, {
state: AppState.SHOWING_CHART,
data,
selection: getSelection(data.chartData, args.selection),
showSidePanel: args.showSidePanel,
}),
);
} catch (error) {
this.setError(getI18nMessage(error, this.props.intl));
} }
} else if (
this.state.state === AppState.SHOWING_CHART || if (
this.state.state === AppState.LOADING_MORE state === AppState.INITIAL ||
) { isNewData(args.sourceSpec, args.selection)
// Update selection if it has changed in the URL. ) {
const selection = getSelection( // Set loading state.
this.state.data!.chartData, setState(AppState.LOADING);
args.selection, // Set state from URL parameters.
); setSourceSpec(args.sourceSpec);
const loadMoreFromWikitree = setSelection(args.selection);
args.sourceSpec.source === DataSourceEnum.WIKITREE && setStandalone(args.standalone);
(!this.state.selection || this.state.selection.id !== selection.id); setChartType(args.chartType);
this.updateDisplay(selection, { setConfig(args.config);
chartType: args.chartType,
state: loadMoreFromWikitree
? AppState.LOADING_MORE
: AppState.SHOWING_CHART,
});
if (loadMoreFromWikitree) {
try { try {
const data = await loadWikiTree(args.selection!.id, this.props.intl); const data = await loadData(args.sourceSpec, args.selection);
const selection = getSelection(data.chartData, args.selection); // Set state with data.
this.setState( setData(data);
Object.assign({}, this.state, { setSelection(getSelection(data.chartData, args.selection));
state: AppState.SHOWING_CHART, setShowSidePanel(args.showSidePanel);
data, setState(AppState.SHOWING_CHART);
selection, } catch (error: any) {
}), setErrorMessage(getI18nMessage(error, props.intl));
); }
} catch (error) { } else if (
this.showErrorPopup( state === AppState.SHOWING_CHART ||
this.props.intl.formatMessage( state === AppState.LOADING_MORE
{ ) {
id: 'error.failed_wikitree_load_more', // Update selection if it has changed in the URL.
defaultMessage: 'Failed to load data from WikiTree. {error}', const newSelection = getSelection(data!.chartData, args.selection);
}, const loadMoreFromWikitree =
{error}, args.sourceSpec.source === DataSourceEnum.WIKITREE &&
), (!selection || selection.id !== newSelection.id);
{state: AppState.SHOWING_CHART}, setChartType(args.chartType);
); setState(
loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART,
);
updateDisplay(newSelection);
if (loadMoreFromWikitree) {
try {
const data = await loadWikiTree(args.selection!.id, props.intl);
const newSelection = getSelection(data.chartData, args.selection);
setData(data);
setSelection(newSelection);
setState(AppState.SHOWING_CHART);
} catch (error: any) {
setState(AppState.SHOWING_CHART);
displayErrorPopup(
props.intl.formatMessage(
{
id: 'error.failed_wikitree_load_more',
defaultMessage: 'Failed to load data from WikiTree. {error}',
},
{error},
),
);
}
} }
} }
} })();
} });
private updateUrl(args: queryString.ParsedQuery<any>) { function updateUrl(args: queryString.ParsedQuery<any>) {
const location = this.props.location; const location = props.location;
const search = queryString.parse(location.search); const search = queryString.parse(location.search);
for (const key in args) { for (const key in args) {
search[key] = args[key]; search[key] = args[key];
} }
location.search = queryString.stringify(search); location.search = queryString.stringify(search);
this.props.history.push(location); props.history.push(location);
} }
/** /**
* Called when the user clicks an individual box in the chart. * Called when the user clicks an individual box in the chart.
* Updates the browser URL. * Updates the browser URL.
*/ */
private onSelection = (selection: IndiInfo) => { function onSelection(selection: IndiInfo) {
// Don't allow selecting WikiTree private profiles. // Don't allow selecting WikiTree private profiles.
if (selection.id.startsWith(PRIVATE_ID_PREFIX)) { if (selection.id.startsWith(PRIVATE_ID_PREFIX)) {
return; return;
} }
analyticsEvent('selection_changed'); analyticsEvent('selection_changed');
this.updateUrl({ updateUrl({
indi: selection.id, indi: selection.id,
gen: selection.generation, gen: selection.generation,
}); });
};
private onPrint = () => {
analyticsEvent('print');
printChart();
};
private showErrorPopup(message: string, otherStateChanges?: Partial<State>) {
this.setState(
Object.assign(
{},
this.state,
{
showErrorPopup: true,
error: message,
},
otherStateChanges,
),
);
} }
private onDownloadPdf = async () => { function onPrint() {
analyticsEvent('print');
printChart();
}
function displayErrorPopup(message: string) {
setShowErrorPopup(true);
setError(message);
}
async function onDownloadPdf() {
analyticsEvent('download_pdf'); analyticsEvent('download_pdf');
try { try {
await downloadPdf(); await downloadPdf();
} catch (e) { } catch (e) {
this.showErrorPopup( displayErrorPopup(
this.props.intl.formatMessage({ props.intl.formatMessage({
id: 'error.failed_pdf', id: 'error.failed_pdf',
defaultMessage: defaultMessage:
'Failed to generate PDF file.' + 'Failed to generate PDF file.' +
@@ -455,15 +418,15 @@ class AppComponent extends React.Component<
}), }),
); );
} }
}; }
private onDownloadPng = async () => { async function onDownloadPng() {
analyticsEvent('download_png'); analyticsEvent('download_png');
try { try {
await downloadPng(); await downloadPng();
} catch (e) { } catch (e) {
this.showErrorPopup( displayErrorPopup(
this.props.intl.formatMessage({ props.intl.formatMessage({
id: 'error.failed_png', id: 'error.failed_png',
defaultMessage: defaultMessage:
'Failed to generate PNG file.' + 'Failed to generate PNG file.' +
@@ -471,49 +434,42 @@ class AppComponent extends React.Component<
}), }),
); );
} }
}; }
private onDownloadSvg = () => { function onDownloadSvg() {
analyticsEvent('download_svg'); analyticsEvent('download_svg');
downloadSvg(); downloadSvg();
}; }
private onDismissErrorPopup = () => { function onDismissErrorPopup() {
this.setState( setShowErrorPopup(false);
Object.assign({}, this.state, { }
showErrorPopup: false,
}),
);
};
private renderMainArea = () => { function renderMainArea() {
switch (this.state.state) { switch (state) {
case AppState.SHOWING_CHART: case AppState.SHOWING_CHART:
case AppState.LOADING_MORE: case AppState.LOADING_MORE:
const sidePanelTabs = [ const sidePanelTabs = [
{ {
menuItem: this.props.intl.formatMessage({ menuItem: props.intl.formatMessage({
id: 'tab.info', id: 'tab.info',
defaultMessage: 'Info', defaultMessage: 'Info',
}), }),
render: () => ( render: () => (
<Details <Details gedcom={data!.gedcom} indi={selection!.id} />
gedcom={this.state.data!.gedcom}
indi={this.state.selection!.id}
/>
), ),
}, },
{ {
menuItem: this.props.intl.formatMessage({ menuItem: props.intl.formatMessage({
id: 'tab.settings', id: 'tab.settings',
defaultMessage: 'Settings', defaultMessage: 'Settings',
}), }),
render: () => ( render: () => (
<ConfigPanel <ConfigPanel
config={this.state.config} config={config}
onChange={(config) => { onChange={(config) => {
this.setState(Object.assign({}, this.state, {config})); setConfig(config);
this.updateUrl(configToArgs(config)); updateUrl(configToArgs(config));
}} }}
/> />
), ),
@@ -522,22 +478,22 @@ class AppComponent extends React.Component<
return ( return (
<div id="content"> <div id="content">
<ErrorPopup <ErrorPopup
open={this.state.showErrorPopup} open={showErrorPopup}
message={this.state.error} message={error}
onDismiss={this.onDismissErrorPopup} onDismiss={onDismissErrorPopup}
/> />
{this.state.state === AppState.LOADING_MORE ? ( {state === AppState.LOADING_MORE ? (
<Loader active size="small" className="loading-more" /> <Loader active size="small" className="loading-more" />
) : null} ) : null}
<Chart <Chart
data={this.state.data!.chartData} data={data!.chartData}
selection={this.state.selection!} selection={selection!}
chartType={this.state.chartType} chartType={chartType}
onSelection={this.onSelection} onSelection={onSelection}
freezeAnimation={this.state.freezeAnimation} freezeAnimation={freezeAnimation}
colors={this.state.config.color} colors={config.color}
/> />
{this.state.showSidePanel ? ( {showSidePanel ? (
<Media greaterThanOrEqual="large" className="sidePanel"> <Media greaterThanOrEqual="large" className="sidePanel">
<Tab panes={sidePanelTabs} /> <Tab panes={sidePanelTabs} />
</Media> </Media>
@@ -547,52 +503,48 @@ class AppComponent extends React.Component<
); );
case AppState.ERROR: case AppState.ERROR:
return <ErrorMessage message={this.state.error!} />; return <ErrorMessage message={error!} />;
case AppState.INITIAL: case AppState.INITIAL:
case AppState.LOADING: case AppState.LOADING:
return <Loader active size="large" />; return <Loader active size="large" />;
} }
};
render() {
return (
<>
<Route
render={(props: RouteComponentProps) => (
<TopBar
{...props}
data={this.state.data && this.state.data.chartData}
allowAllRelativesChart={
this.state.sourceSpec?.source !== DataSourceEnum.WIKITREE
}
showingChart={
this.props.history.location.pathname === '/view' &&
(this.state.state === AppState.SHOWING_CHART ||
this.state.state === AppState.LOADING_MORE)
}
standalone={this.state.standalone}
eventHandlers={{
onSelection: this.onSelection,
onPrint: this.onPrint,
onDownloadPdf: this.onDownloadPdf,
onDownloadPng: this.onDownloadPng,
onDownloadSvg: this.onDownloadSvg,
}}
showWikiTreeMenus={
this.state.sourceSpec?.source === DataSourceEnum.WIKITREE
}
/>
)}
/>
<Switch>
<Route exact path="/" component={Intro} />
<Route exact path="/view" render={this.renderMainArea} />
<Redirect to={'/'} />
</Switch>
</>
);
} }
return (
<>
<Route
render={(props: RouteComponentProps) => (
<TopBar
{...props}
data={data?.chartData}
allowAllRelativesChart={
sourceSpec?.source !== DataSourceEnum.WIKITREE
}
showingChart={
props.history.location.pathname === '/view' &&
(state === AppState.SHOWING_CHART ||
state === AppState.LOADING_MORE)
}
standalone={standalone}
eventHandlers={{
onSelection,
onPrint,
onDownloadPdf,
onDownloadPng,
onDownloadSvg,
}}
showWikiTreeMenus={sourceSpec?.source === DataSourceEnum.WIKITREE}
/>
)}
/>
<Switch>
<Route exact path="/" component={Intro} />
<Route exact path="/view" render={renderMainArea} />
<Redirect to={'/'} />
</Switch>
</>
);
} }
export const App = injectIntl(AppComponent); export const App = injectIntl(AppComponent);