wstl1/tools/notebook/data-model-browser/src/components/data_model_viewer.tsx (394 lines of code) (raw):
/**
* @jsx React.createElement
* @jsxFrag React.Fragment
*/
// Copyright 2020 Google LLC.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as csstips from 'csstips';
// tslint:disable-next-line:enforce-name-casing
import * as React from 'react';
import {stylesheet} from 'typestyle';
import BreadCrumb from '@material-ui/core/Breadcrumbs';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import Typography from '@material-ui/core/Typography';
import {BreadCrumbItem} from './bread_crumb_item';
import {DataModelService, DataModel} from '../service/data_model_service';
import {FieldMetadataViewer} from './field_meta_data_viewer';
import {SearchBar} from './search_bar';
import {SubEntitiesViewer} from './sub_entities_viewer';
import {TopEntitiesViewer} from './top_entities_viewer';
import {SubEntityMeta, TopEntityMeta, SearchResult} from './types';
/**
* Properites for the DataModelViewer component.
*/
interface Props {
/**
* The service that wrappers API requests to the extension's backend handler.
*/
dataModelService: DataModelService;
/**
* A flag indicating whether the components is visible or not.
*/
isVisible: boolean;
}
/**
* The state maintained by the DataModelViewer component.
*/
interface State {
/**
* The JSON path representing the field selected for inspection which will
* result in a tool tip being displayed with additional information.
*/
inspectPath: string;
/**
* The search result selected from the drop down list of candidate search
* results.
*/
selectedSearchResult: SearchResult;
/**
* The path of the field that has been selected to drill down into.
*/
selectedPath: string[];
/**
* The list of available data models.
*/
dataModels: DataModel[];
/**
* The currently active data model.
*/
activeDataModel: DataModel;
/**
* The ID of the active data model.
*/
activeDataModelId: string;
/**
* Flag indicating that the data models have finished loading.
*/
hasLoaded: boolean;
/**
* A cache of the top level entities in the active data model.
*/
topEntities: TopEntityMeta[];
}
/**
* The JSON schema definition of an entity.
*/
interface ResourceDefinition {
/**
* The name of the resource.
*/
name: string;
/**
* The JSON schema definition of the resource.
*/
// tslint:disable-next-line:no-any
definition: any;
}
/**
* Convenience class for a subset of the properties of a field.
*/
interface FieldProperties {
/**
* The field type.
*/
type: string;
/**
* Flag indicating if the field is clickable.
*/
clickable: boolean;
}
const localStyles = stylesheet({
header: {
borderBottom: 'var(--jp-border-width) solid var(--jp-border-color2)',
fontWeight: 600,
fontSize: 'var(--jp-ui-font-size0, 11px)',
letterSpacing: '1px',
margin: 0,
padding: '8px 12px',
textTransform: 'uppercase',
},
panel: {
backgroundColor: 'white',
height: '100%',
width: '100%',
...csstips.vertical,
},
select: {
margin: 'auto 16px',
},
viewer: {
width: '100%',
backgroundColor: 'white',
height: 'calc(100% - 50px)',
overflowY: 'auto' as 'auto',
},
tree: {
height: 110,
flexGrow: 1,
maxWidth: 400,
overflowY: 'scroll',
...csstips.flex,
},
table: {
minWidth: 150,
},
button: {
height: '100%',
},
selectLabelRoot: {
color: 'black',
fontSize: '1.3rem',
fontWeight: 500,
},
tableName: {
color: 'black',
fontSize: '0.9rem',
fontWeight: 600,
padding: '15px 2px 5px 2px',
margin: 'auto 16px',
},
selectRoot: {
minWidth: '10rem',
},
});
const HEADER_TITLE = 'Data Model Browser Extension';
/**
* Component for visualizing a data model schema definition.
*/
export class DataModelViewer extends React.Component<Props, State> {
state: State = {
inspectPath: '',
selectedSearchResult: null,
selectedPath: ['FHIR'],
dataModels: [],
activeDataModel: null,
activeDataModelId: 'FHIR',
hasLoaded: false,
topEntities: [],
};
constructor(props: Props) {
super(props);
}
async componentDidMount() {
try {
this.getDataModels();
} catch (err) {
console.warn('Unexpected error', err);
}
}
/**
* Callback invoked when a BreadCrumbItem is clicked.
*
* @param path - the JSON path, spread across a list, from the root of the
* resource to the selected item.
*/
onListItemClicked = (path: string[]) => {
this.setState({
selectedPath: path,
});
};
/**
* Callback invoked when either a TopEntity or SubEntity is selected for
* tool tip inspection.
*
* @param path - the JSON path from the root of the resource to the selected
* item.
*/
onEntityInspected = (path: string) => {
this.setState({
inspectPath: path,
});
};
/**
* Callback invoked when either a TopEntity or SubEntity is selectd for
* further drill down.
*
* @param name - the name of the entity that was selected.
*/
onEntitySelected = (name: string) => {
this.setState({
selectedPath: this.state.selectedPath.concat([name]),
inspectPath: '',
});
};
render() {
if (!this.state.hasLoaded) {
return (
<div className={localStyles.panel}>
<header className={localStyles.header}>
{HEADER_TITLE}
</header>
<Typography color="textPrimary">Loading...</Typography>
</div>
);
}
if (this.state.selectedPath.length === 0) {
return (
<div className={localStyles.panel}>
<header className={localStyles.header}>
{HEADER_TITLE}
</header>
<Typography color="textPrimary">Missing data model schema</Typography>
</div>
);
} else if (this.state.selectedPath.length === 1) {
// No resources have been selected so render the resource list.
return (
<div className={localStyles.panel}>
<header className={localStyles.header}>
{HEADER_TITLE}
</header>
<div className={localStyles.tableName}>
Select version
</div>
<InputLabel></InputLabel>
<Select
value={this.state.activeDataModelId}
className={localStyles.select}
disabled
>
<MenuItem value="FHIR">{'FHIR-stu3'}</MenuItem>
</Select>
<div className={localStyles.tableName}>
Search tables and fields
</div>
<SearchBar
dataModel={this.state.activeDataModel}
onSearchResultSelected={this.onSearchResultSelected}
/>
<div className={localStyles.tableName}>
Resource name
</div>
<div className={localStyles.viewer}>
<TopEntitiesViewer
topEntities={this.state.topEntities}
onInspect={this.onEntityInspected}
onSelect={this.onEntitySelected}
selected={this.state.inspectPath}
/>
</div>
</div>
);
}
const previousPaths = this.state.selectedPath.slice(
0,
this.state.selectedPath.length - 1
);
const activePath = this.state.selectedPath[
this.state.selectedPath.length - 1
];
let pathOffset = 0;
return (
<div className={localStyles.panel}>
<div className="bread-crumb-wrapper">
<BreadCrumb maxItems={3} aria-label="breadcrumb">
{previousPaths.map(previousPath => {
return (
<BreadCrumbItem
key={previousPath}
path={this.state.selectedPath.slice(0, ++pathOffset)}
label={previousPath}
onClick={this.onListItemClicked}
/>
);
})}
<Typography color="textPrimary">{activePath}</Typography>
</BreadCrumb>
</div>
<div className={localStyles.tableName}>
Select or hover over a resource for more details
</div>
<SearchBar
dataModel={this.state.activeDataModel}
onSearchResultSelected={this.onSearchResultSelected}
/>
<div className={localStyles.viewer}>
{this.getSelectionDetails(
this.state.selectedPath.slice(1),
this.state.activeDataModel.schema
)}
</div>
</div>
);
}
private async getDataModels() {
try {
const dm = await this.props.dataModelService.listModels();
if (dm.dataModels.length === 0) {
console.warn('Error retrived empty data models list');
throw new Error('Error retrived empty data models list');
}
this.setState({activeDataModel: dm.dataModels[0]});
const topEntities: TopEntityMeta[] = Object.keys(
this.state.activeDataModel.schema.discriminator.mapping
)
.sort()
.map(key => {
return this.extractTopEntityMeta(key);
});
this.setState({topEntities});
this.setState({hasLoaded: true, dataModels: dm.dataModels});
} catch (err) {
console.warn('Error retrieving data models', err);
}
}
private onSearchResultSelected = (path: string) => {
if (!path || path.length === 0) {
this.setState({selectedSearchResult: null});
this.setState({selectedPath: [this.state.activeDataModelId]});
} else {
const splits = [this.state.activeDataModelId].concat(path.split(' > '));
this.setState({selectedPath: splits});
const searchResult: SearchResult = {
path,
resource: splits[0],
field: '',
};
if (splits.length > 1) {
searchResult.field = splits[1];
}
this.setState({selectedSearchResult: searchResult});
}
};
private extractTopEntityMeta = (name: string): TopEntityMeta => {
const description = this.state.activeDataModel.schema.definitions[name]
.description;
return {name, description};
};
// tslint:disable-next-line:no-any (data from JSON schema)
private extractReferenceType = (fieldDetails: any): string => {
return fieldDetails.$ref.slice(fieldDetails.$ref.lastIndexOf('/') + 1);
};
// tslint:disable-next-line:no-any (data from JSON schema)
private resolveFieldProperties = (field: string, fieldDetails: any): FieldProperties => {
const fieldProp: FieldProperties = {type: '', clickable: false};
if ('type' in fieldDetails) {
if ('items' in fieldDetails && '$ref' in fieldDetails.items) {
fieldProp.type = this.extractReferenceType(fieldDetails.items);
} else {
fieldProp.type = fieldDetails.type;
}
fieldProp.clickable = true;
} else if ('const' in fieldDetails) {
fieldProp.type = 'constant: ' + fieldDetails.const;
fieldProp.clickable = false;
} else if ('enum' in fieldDetails) {
fieldProp.type = 'enum: ' + fieldDetails.enum.join(', ');
fieldProp.clickable = false;
} else if ('$ref' in fieldDetails) {
fieldProp.type = this.extractReferenceType(fieldDetails);
fieldProp.clickable = true;
}
return fieldProp;
};
// tslint:disable-next-line:no-any (data from JSON schema)
private getSubEntityDetails = (name: string, schema: any): SubEntityMeta[] => {
const subEntities: SubEntityMeta[] = [];
const definition = schema.definitions[name];
if (!!definition && 'properties' in definition) {
for (const [field] of Object.entries(definition.properties).sort()) {
if (!field.startsWith('_')) {
let isRequired = false;
if ('required' in definition) {
isRequired = definition.required.includes(field);
}
const fieldDetails = definition.properties[field];
const fieldProperty = this.resolveFieldProperties(field, fieldDetails);
const subEntity: SubEntityMeta = {
name: field,
required: isRequired,
type: fieldProperty.type,
description: fieldDetails.description,
schema: fieldDetails,
clickable: fieldProperty.clickable,
};
subEntities.push(subEntity);
}
}
}
return subEntities;
};
private getSelectionDetails = (
pathParts: string[],
// tslint:disable-next-line:no-any (data from JSON schema)
schema: any
): React.ReactNode => {
if (pathParts.length <= 1) {
const subEntities = this.getSubEntityDetails(pathParts[0], schema);
return (
<SubEntitiesViewer
subEntities={subEntities}
onInspect={this.onEntityInspected}
onSelect={this.onEntitySelected}
selected={this.state.inspectPath}
/>
);
}
let parent: ResourceDefinition = {
name: pathParts[0],
definition: schema.definitions[pathParts[0]],
};
let i = 1;
while (parent != null) {
if (i >= pathParts.length) {
break;
}
const field = pathParts[i++];
if (
!('properties' in parent.definition) ||
!(field in parent.definition.properties)
) {
break;
}
const fieldDetails = parent.definition.properties[field];
parent = null;
if (!!fieldDetails) {
const fieldProperty = this.resolveFieldProperties(field, fieldDetails);
if (fieldProperty.type in schema.definitions) {
parent = {
name: fieldProperty.type,
definition: schema.definitions[fieldProperty.type],
};
}
}
}
if (!parent) {
parent = {
name: pathParts[i - 1],
definition: schema.definitions[pathParts[i - 1]],
};
}
if ('properties' in parent.definition) {
// either a complex type or a primitive base type.
const subEntities: SubEntityMeta[] = this.getSubEntityDetails(
parent.name,
schema
);
return (
<SubEntitiesViewer
subEntities={subEntities}
onInspect={this.onEntityInspected}
onSelect={this.onEntitySelected}
selected={this.state.inspectPath}
/>
);
} else {
const fieldProperty = this.resolveFieldProperties(parent.name, parent.definition);
const subEntity: SubEntityMeta = {
name:
'pattern' in parent.definition
? parent.definition.pattern
: parent.name,
required: false,
type: fieldProperty.type,
description: parent.definition.description,
clickable: fieldProperty.clickable,
};
return <FieldMetadataViewer fieldMetadata={subEntity} />;
}
};
}