Refactoring: slowly detaching data loading code from the main app

This commit is contained in:
Przemek Wiech
2020-05-12 18:26:14 +02:00
parent 7e0a72b3e9
commit 3eb48ce665
2 changed files with 205 additions and 122 deletions

View File

@@ -83,37 +83,80 @@ interface GedcomMessage extends EmbeddedMessage {
gedcom?: string; gedcom?: string;
} }
/** Supported data sources. */
enum DataSourceEnum {
UPLOADED,
GEDCOM_URL,
WIKITREE,
}
interface UploadSourceSpec {
source: DataSourceEnum.UPLOADED;
gedcom?: string;
/** Hash of the GEDCOM contents. */
hash: string;
images?: Map<string, string>;
}
interface UrlSourceSpec {
source: DataSourceEnum.GEDCOM_URL;
/** URL of the data that is loaded or is being loaded. */
url: string;
handleCors: boolean;
}
interface WikiTreeSourceSpec {
source: DataSourceEnum.WIKITREE;
authcode?: string;
}
type DataSourceSpec = UrlSourceSpec | UploadSourceSpec | WikiTreeSourceSpec;
/** Source specification together with individual selection. */
interface SourceSelection<SourceSpecT> {
spec: SourceSpecT;
selection?: IndiInfo;
}
/** Interface encapsulating functions specific for a data source. */ /** Interface encapsulating functions specific for a data source. */
interface DataSource { interface DataSource<SourceSpecT> {
/** /**
* Returns true if the application is now loading a completely new data set * Returns true if the application is now loading a completely new data set
* and the existing one should be wiped. * and the existing one should be wiped.
*/ */
isNewData(args: Arguments, state: State): boolean; isNewData(
newSource: SourceSelection<SourceSpecT>,
oldSource: SourceSelection<SourceSpecT>,
data?: TopolaData,
): boolean;
/** Loads data from the data source. */ /** Loads data from the data source. */
loadData(args: Arguments): Promise<TopolaData>; loadData(spec: SourceSelection<SourceSpecT>): Promise<TopolaData>;
} }
/** Files opened from the local computer. */ /** Files opened from the local computer. */
class UploadedDataSource implements DataSource { class UploadedDataSource implements DataSource<UploadSourceSpec> {
isNewData(args: Arguments, state: State): boolean { // isNewData(args: Arguments, state: State): boolean {
return ( isNewData(
args.hash !== state.hash || newSource: SourceSelection<UploadSourceSpec>,
!!( oldSource: SourceSelection<UploadSourceSpec>,
args.gedcom && data?: TopolaData,
state.state !== AppState.LOADING && ): boolean {
state.state !== AppState.SHOWING_CHART return newSource.spec.hash !== oldSource.spec.hash;
)
);
} }
async loadData(args: Arguments): Promise<TopolaData> { async loadData(
source: SourceSelection<UploadSourceSpec>,
): Promise<TopolaData> {
try { try {
const data = await loadGedcom(args.hash!, args.gedcom, args.images); const data = await loadGedcom(
source.spec.hash,
source.spec.gedcom,
source.spec.images,
);
const software = getSoftware(data.gedcom.head); const software = getSoftware(data.gedcom.head);
analyticsEvent('upload_file_loaded', { analyticsEvent('upload_file_loaded', {
event_label: software, event_label: software,
event_value: (args.images && args.images.size) || 0, event_value: (source.spec.images && source.spec.images.size) || 0,
}); });
return data; return data;
} catch (error) { } catch (error) {
@@ -124,14 +167,18 @@ class UploadedDataSource implements DataSource {
} }
/** GEDCOM file loaded by pointing to a URL. */ /** GEDCOM file loaded by pointing to a URL. */
class GedcomUrlDataSource implements DataSource { class GedcomUrlDataSource implements DataSource<UrlSourceSpec> {
isNewData(args: Arguments, state: State): boolean { isNewData(
return args.url !== state.url; newSource: SourceSelection<UrlSourceSpec>,
oldSource: SourceSelection<UrlSourceSpec>,
data?: TopolaData,
): boolean {
return newSource.spec.url !== oldSource.spec.url;
} }
async loadData(args: Arguments): Promise<TopolaData> { async loadData(source: SourceSelection<UrlSourceSpec>): Promise<TopolaData> {
try { try {
const data = await loadFromUrl(args.url!, args.handleCors); const data = await loadFromUrl(source.spec.url, source.spec.handleCors);
const software = getSoftware(data.gedcom.head); const software = getSoftware(data.gedcom.head);
analyticsEvent('upload_file_loaded', {event_label: software}); analyticsEvent('upload_file_loaded', {event_label: software});
return data; return data;
@@ -143,17 +190,24 @@ class GedcomUrlDataSource implements DataSource {
} }
/** Loading data from the WikiTree API. */ /** Loading data from the WikiTree API. */
class WikiTreeDataSource implements DataSource { class WikiTreeDataSource implements DataSource<WikiTreeSourceSpec> {
constructor(private intl: InjectedIntl) {} constructor(private intl: InjectedIntl) {}
isNewData(args: Arguments, state: State): boolean { isNewData(
if (state.selection && state.selection.id === args.indi) { newSource: SourceSelection<WikiTreeSourceSpec>,
oldSource: SourceSelection<WikiTreeSourceSpec>,
data?: TopolaData,
): boolean {
if (!newSource.selection) {
return false;
}
if (oldSource.selection?.id === newSource.selection.id) {
// Selection unchanged -> don't reload. // Selection unchanged -> don't reload.
return false; return false;
} }
if ( if (
state.data && data &&
state.data.chartData.indis.some((indi) => indi.id === args.indi) data.chartData.indis.some((indi) => indi.id === newSource.selection?.id)
) { ) {
// New selection exists in current view -> animate instead of reloading. // New selection exists in current view -> animate instead of reloading.
return false; return false;
@@ -161,9 +215,18 @@ class WikiTreeDataSource implements DataSource {
return true; return true;
} }
async loadData(args: Arguments): Promise<TopolaData> { async loadData(
source: SourceSelection<WikiTreeSourceSpec>,
): Promise<TopolaData> {
if (!source.selection) {
throw new Error('WikiTree id needs to be provided');
}
try { try {
const data = await loadWikiTree(args.indi!, this.intl, args.authcode); const data = await loadWikiTree(
source.selection.id,
this.intl,
source.spec.authcode,
);
analyticsEvent('wikitree_loaded'); analyticsEvent('wikitree_loaded');
return data; return data;
} catch (error) { } catch (error) {
@@ -173,29 +236,15 @@ class WikiTreeDataSource implements DataSource {
} }
} }
/** Supported data sources. */
enum DataSourceEnum {
UPLOADED,
GEDCOM_URL,
WIKITREE,
}
/** Arguments passed to the application, primarily through URL parameters. */ /** Arguments passed to the application, primarily through URL parameters. */
interface Arguments { interface Arguments {
showSidePanel: boolean; sourceSpec?: DataSourceSpec;
embedded: boolean; selection?: IndiInfo;
url?: string;
indi?: string;
generation?: number;
hash?: string;
handleCors: boolean;
standalone: boolean;
source?: DataSourceEnum;
authcode?: string;
chartType: ChartType; chartType: ChartType;
gedcom?: string; embedded: boolean;
images?: Map<string, string>; standalone: boolean;
freezeAnimation?: boolean; freezeAnimation?: boolean;
showSidePanel: boolean;
} }
/** /**
@@ -209,40 +258,51 @@ function getArguments(location: H.Location<any>): Arguments {
return typeof value === 'string' ? value : undefined; return typeof value === 'string' ? value : undefined;
}; };
const parsedGen = Number(getParam('gen'));
const view = getParam('view'); const view = getParam('view');
const chartTypes = new Map<string | undefined, ChartType>([ const chartTypes = new Map<string | undefined, ChartType>([
['relatives', ChartType.Relatives], ['relatives', ChartType.Relatives],
['fancy', ChartType.Fancy], ['fancy', ChartType.Fancy],
]); ]);
const hash = getParam('file'); const hash = getParam('file');
const url = getParam('url'); const url = getParam('url');
const source = var sourceSpec: DataSourceSpec | undefined = undefined;
getParam('source') === 'wikitree' if (getParam('source') === 'wikitree') {
? DataSourceEnum.WIKITREE sourceSpec = {
: hash source: DataSourceEnum.WIKITREE,
? 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'), authcode: getParam('?authcode'),
freezeAnimation: getParam('freeze') === 'true', // False by default };
} else if (hash) {
sourceSpec = {
source: DataSourceEnum.UPLOADED,
hash,
gedcom: location.state && location.state.data,
images: location.state && location.state.images,
};
} else if (url) {
sourceSpec = {
source: DataSourceEnum.GEDCOM_URL,
url,
handleCors: getParam('handleCors') !== 'false', // True by default.
};
}
const indi = getParam('indi');
const parsedGen = Number(getParam('gen'));
const selection = indi
? {id: indi, generation: !isNaN(parsedGen) ? parsedGen : 0}
: undefined;
return {
sourceSpec,
selection,
// Hourglass is the default view. // Hourglass is the default view.
chartType: chartTypes.get(view) || ChartType.Hourglass, chartType: chartTypes.get(view) || ChartType.Hourglass,
gedcom: location.state && location.state.data, showSidePanel: getParam('sidePanel') !== 'false', // True by default.
images: location.state && location.state.images, embedded: getParam('embedded') === 'true', // False by default.
standalone: getParam('standalone') !== 'false', // True by default.
freezeAnimation: getParam('freeze') === 'true', // False by default
}; };
} }
@@ -263,12 +323,8 @@ interface State {
data?: TopolaData; data?: TopolaData;
/** Selected individual. */ /** Selected individual. */
selection?: IndiInfo; selection?: IndiInfo;
/** Hash of the GEDCOM contents. */
hash?: string;
/** Error to display. */ /** Error to display. */
error?: string; error?: string;
/** URL of the data that is loaded or is being loaded. */
url?: string;
/** Whether the side panel is shown. */ /** Whether the side panel is shown. */
showSidePanel?: boolean; showSidePanel?: boolean;
/** Whether the app is in embedded mode, i.e. embedded in an iframe. */ /** Whether the app is in embedded mode, i.e. embedded in an iframe. */
@@ -279,8 +335,8 @@ interface State {
chartType: ChartType; chartType: ChartType;
/** Whether to show the error popup. */ /** Whether to show the error popup. */
showErrorPopup: boolean; showErrorPopup: boolean;
/** Source of the data. */ /** Specification of the source of the data. */
source?: DataSourceEnum; sourceSpec?: DataSourceSpec;
/** Freeze animations after initial chart render. */ /** Freeze animations after initial chart render. */
freezeAnimation?: boolean; freezeAnimation?: boolean;
} }
@@ -300,13 +356,6 @@ export class App extends React.Component<RouteComponentProps, {}> {
intl: intlShape, intl: intlShape,
}; };
/** Mapping from data source identifier to data source handler functions. */
private readonly dataSources = new Map([
[DataSourceEnum.UPLOADED, new UploadedDataSource()],
[DataSourceEnum.GEDCOM_URL, new GedcomUrlDataSource()],
[DataSourceEnum.WIKITREE, new WikiTreeDataSource(this.context.intl)],
]);
/** 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,
@@ -368,6 +417,58 @@ export class App extends React.Component<RouteComponentProps, {}> {
this.componentDidUpdate(); this.componentDidUpdate();
} }
private readonly uploadedDataSource = new UploadedDataSource();
private readonly gedcomUrlDataSource = new GedcomUrlDataSource();
private readonly wikiTreeDataSource = new WikiTreeDataSource(
this.context.intl,
);
private isNewData(sourceSpec: DataSourceSpec, selection?: IndiInfo) {
if (
!this.state.sourceSpec ||
this.state.sourceSpec.source !== sourceSpec.source
) {
// New data source means new data.
return true;
}
const newSource = {spec: sourceSpec, selection};
const oldSouce = {
spec: this.state.sourceSpec,
selection: this.state.selection,
};
switch (newSource.spec.source) {
case DataSourceEnum.UPLOADED:
return this.uploadedDataSource.isNewData(
newSource as SourceSelection<UploadSourceSpec>,
oldSouce as SourceSelection<UploadSourceSpec>,
this.state.data,
);
case DataSourceEnum.GEDCOM_URL:
return this.gedcomUrlDataSource.isNewData(
newSource as SourceSelection<UrlSourceSpec>,
oldSouce as SourceSelection<UrlSourceSpec>,
this.state.data,
);
case DataSourceEnum.WIKITREE:
return this.wikiTreeDataSource.isNewData(
newSource as SourceSelection<WikiTreeSourceSpec>,
oldSouce as SourceSelection<WikiTreeSourceSpec>,
this.state.data,
);
}
}
private loadData(sourceSpec: DataSourceSpec, selection?: IndiInfo) {
switch (sourceSpec.source) {
case DataSourceEnum.UPLOADED:
return this.uploadedDataSource.loadData({spec: sourceSpec, selection});
case DataSourceEnum.GEDCOM_URL:
return this.gedcomUrlDataSource.loadData({spec: sourceSpec, selection});
case DataSourceEnum.WIKITREE:
return this.wikiTreeDataSource.loadData({spec: sourceSpec, selection});
}
}
async componentDidUpdate() { async componentDidUpdate() {
if (this.props.location.pathname !== '/view') { if (this.props.location.pathname !== '/view') {
if (this.state.state !== AppState.INITIAL) { if (this.state.state !== AppState.INITIAL) {
@@ -397,43 +498,30 @@ export class App extends React.Component<RouteComponentProps, {}> {
return; return;
} }
const dataSource = this.dataSources.get(args.source!); if (!args.sourceSpec) {
if (!dataSource) {
this.props.history.replace({pathname: '/'}); this.props.history.replace({pathname: '/'});
} else if ( } else if (
this.state.state === AppState.INITIAL || this.state.state === AppState.INITIAL ||
args.source !== this.state.source || this.isNewData(args.sourceSpec, args.selection)
dataSource.isNewData(args, this.state)
) { ) {
// Set loading state. // Set loading state.
this.setState( this.setState(
Object.assign({}, this.state, { Object.assign({}, this.state, {
state: AppState.LOADING, state: AppState.LOADING,
selection: {id: args.indi}, sourceSpec: args.sourceSpec,
hash: args.hash, selection: args.selection,
url: args.url,
standalone: args.standalone, standalone: args.standalone,
chartType: args.chartType, chartType: args.chartType,
source: args.source,
}), }),
); );
try { try {
const data = await dataSource.loadData(args); const data = await this.loadData(args.sourceSpec, args.selection);
// Set state with data. // Set state with data.
this.setState( this.setState(
Object.assign({}, this.state, { Object.assign({}, this.state, {
state: AppState.SHOWING_CHART, state: AppState.SHOWING_CHART,
data, data,
hash: args.hash, selection: getSelection(data.chartData, args.selection),
selection: getSelection(data.chartData, args.indi, args.generation),
url: args.url,
showSidePanel: args.showSidePanel,
standalone: args.standalone,
chartType: args.chartType,
source: args.source,
freezeAnimation: args.freezeAnimation,
}), }),
); );
} catch (error) { } catch (error) {
@@ -446,11 +534,10 @@ export class App extends React.Component<RouteComponentProps, {}> {
// 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,
args.indi, args.selection,
args.generation,
); );
const loadMoreFromWikitree = const loadMoreFromWikitree =
args.source === DataSourceEnum.WIKITREE && args.sourceSpec.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: args.chartType, chartType: args.chartType,
@@ -460,23 +547,16 @@ export class App extends React.Component<RouteComponentProps, {}> {
}); });
if (loadMoreFromWikitree) { if (loadMoreFromWikitree) {
try { try {
const data = await loadWikiTree(args.indi!, this.context.intl); const data = await loadWikiTree(
const selection = getSelection( args.selection!.id,
data.chartData, this.context.intl,
args.indi,
args.generation,
); );
const selection = getSelection(data.chartData, args.selection);
this.setState( this.setState(
Object.assign({}, this.state, { Object.assign({}, this.state, {
state: AppState.SHOWING_CHART, state: AppState.SHOWING_CHART,
data, data,
hash: args.hash,
selection, selection,
url: args.url,
showSidePanel: args.showSidePanel,
standalone: args.standalone,
chartType: args.chartType,
source: args.source,
}), }),
); );
} catch (error) { } catch (error) {
@@ -633,7 +713,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
{...props} {...props}
data={this.state.data && this.state.data.chartData} data={this.state.data && this.state.data.chartData}
allowAllRelativesChart={ allowAllRelativesChart={
this.state.source !== DataSourceEnum.WIKITREE this.state.sourceSpec?.source !== DataSourceEnum.WIKITREE
} }
showingChart={ showingChart={
this.props.history.location.pathname === '/view' && this.props.history.location.pathname === '/view' &&
@@ -648,7 +728,9 @@ export class App extends React.Component<RouteComponentProps, {}> {
onDownloadPng: this.onDownloadPng, onDownloadPng: this.onDownloadPng,
onDownloadSvg: this.onDownloadSvg, onDownloadSvg: this.onDownloadSvg,
}} }}
showWikiTreeMenus={this.state.source === DataSourceEnum.WIKITREE} showWikiTreeMenus={
this.state.sourceSpec?.source === DataSourceEnum.WIKITREE
}
/> />
)} )}
/> />

View File

@@ -7,14 +7,15 @@ import {IndiInfo, JsonGedcomData} from 'topola';
*/ */
export function getSelection( export function getSelection(
data: JsonGedcomData, data: JsonGedcomData,
indi?: string, selection?: IndiInfo,
generation?: number,
): IndiInfo { ): IndiInfo {
// If ID is not given or it doesn't exist in the data, use the first ID in // If ID is not given or it doesn't exist in the data, use the first ID in
// the data. // the data.
const id = const id =
indi && data.indis.some((i) => i.id === indi) ? indi : data.indis[0].id; selection && data.indis.some((i) => i.id === selection.id)
return {id, generation: generation || 0}; ? selection.id
: data.indis[0].id;
return {id, generation: selection?.generation || 0};
} }
function prepareData( function prepareData(