import * as React from 'react'; import { Dictionary, ElementModel, ElementIri, LinkTypeIri } from '../data/model'; import { FatLinkType, Element } from '../diagram/elements'; import { DiagramView } from '../diagram/view'; import { EditorController } from '../editor/editorController'; import { EventObserver } from '../viewUtils/events'; import { ProgressBar, ProgressState } from '../widgets/progressBar'; import { highlightSubstring } from './listElementView'; import { SearchResults } from './searchResults'; import { WorkspaceContextTypes, WorkspaceContextWrapper, WorkspaceEventKey } from '../workspace/workspaceContext'; interface ConnectionCount { inCount: number; outCount: number; } export interface ReactElementModel { model: ElementModel; presentOnDiagram: boolean; } const MAX_LINK_COUNT = 100; const ALL_RELATED_ELEMENTS_LINK: FatLinkType = new FatLinkType({ id: 'allRelatedElements' as LinkTypeIri, label: [{value: 'All', language: ''}], }); export interface PropertySuggestionParams { elementId: string; token: string; properties: string[]; lang: string; } export type PropertySuggestionHandler = (params: PropertySuggestionParams) => Promise>; type SortMode = 'alphabet' | 'smart'; export interface PropertyScore { propertyIri: string; score: number; } export interface LinkDataChunk { link: FatLinkType; direction?: 'in' | 'out'; expectedCount: number; offset?: number; } export interface ObjectsData { linkDataChunk: LinkDataChunk; objects: ReactElementModel[]; } export interface ConnectionsMenuProps { view: DiagramView; editor: EditorController; target: Element; onClose: () => void; onAddElements: (elementIris: ElementIri[], linkType: FatLinkType | undefined) => void; suggestProperties?: PropertySuggestionHandler; } export class ConnectionsMenu extends React.Component { static contextTypes = WorkspaceContextTypes; readonly context: WorkspaceContextWrapper; private readonly handler = new EventObserver(); private readonly linkTypesListener = new EventObserver(); private loadingState = ProgressState.none; private links: FatLinkType[]; private countMap: { [linkTypeId: string]: ConnectionCount }; private linkDataChunk: LinkDataChunk; private objects: ReactElementModel[]; private updateAll = () => this.forceUpdate(); componentDidMount() { const {view} = this.props; this.handler.listen(view.events, 'changeLanguage', this.updateAll); this.loadLinks(); } componentWillUnmount() { this.handler.stopListening(); this.linkTypesListener.stopListening(); } private resubscribeOnLinkTypeEvents(linkTypesOfElement: ReadonlyArray) { this.linkTypesListener.stopListening(); for (const linkType of linkTypesOfElement) { this.linkTypesListener.listen(linkType.events, 'changeLabel', this.updateAll); this.linkTypesListener.listen(linkType.events, 'changeVisibility', this.updateAll); } } private loadLinks() { const {view, editor, target} = this.props; this.loadingState = ProgressState.loading; this.links = []; this.countMap = {}; editor.model.dataProvider.linkTypesOf({elementId: target.iri}) .then(linkTypes => { this.loadingState = ProgressState.completed; const countMap: Dictionary = {}; const links: FatLinkType[] = []; for (const {id: linkTypeId, inCount, outCount} of linkTypes) { countMap[linkTypeId] = {inCount, outCount}; links.push(view.model.createLinkType(linkTypeId)); } countMap[ALL_RELATED_ELEMENTS_LINK.id] = Object.keys(countMap) .map(key => countMap[key]) .reduce((a, b) => { return {inCount: a.inCount + b.inCount, outCount: a.outCount + b.outCount}; }, {inCount: 0, outCount: 0}); this.countMap = countMap; this.links = links; this.resubscribeOnLinkTypeEvents(this.links); this.updateAll(); this.context.ontodiaWorkspace.triggerWorkspaceEvent(WorkspaceEventKey.connectionsLoadLinks); }) .catch(err => { // tslint:disable-next-line:no-console console.error(err); this.loadingState = ProgressState.error; this.updateAll(); }); this.updateAll(); } private loadObjects(linkDataChunk: LinkDataChunk) { const {view, editor, target} = this.props; const {link, direction } = linkDataChunk; const offset = (linkDataChunk.offset || 0); this.loadingState = ProgressState.loading; this.linkDataChunk = linkDataChunk; this.objects = []; editor.model.dataProvider.linkElements({ elementId: target.iri, linkId: (link === ALL_RELATED_ELEMENTS_LINK ? undefined : link.id), limit: offset + MAX_LINK_COUNT, offset: offset, direction, }).then(elements => { this.loadingState = ProgressState.completed; this.objects = Object.keys(elements).map(iri => ({ model: elements[iri], presentOnDiagram: view.model.elements.findIndex( element => element.iri === iri && element.group === undefined ) >= 0, })); this.updateAll(); this.context.ontodiaWorkspace.triggerWorkspaceEvent(WorkspaceEventKey.connectionsLoadElements); }).catch(err => { // tslint:disable-next-line:no-console console.error(err); this.loadingState = ProgressState.error; this.updateAll(); }); } private addSelectedElements = (selectedObjects: ReactElementModel[]) => { const {onClose, onAddElements} = this.props; const addedElementsIris = selectedObjects.map(item => item.model.id); const linkType = this.linkDataChunk ? this.linkDataChunk.link : undefined; const hasChosenLinkType = this.linkDataChunk && linkType !== ALL_RELATED_ELEMENTS_LINK; onAddElements(addedElementsIris, hasChosenLinkType ? linkType : undefined); onClose(); } private onExpandLink = (linkDataChunk: LinkDataChunk) => { const alreadyLoaded = ( this.objects && this.linkDataChunk && this.linkDataChunk.link === linkDataChunk.link && this.linkDataChunk.direction === linkDataChunk.direction ); if (!alreadyLoaded) { this.loadObjects(linkDataChunk); } this.updateAll(); this.context.ontodiaWorkspace.triggerWorkspaceEvent(WorkspaceEventKey.connectionsExpandLink); } private onMoveToFilter = (linkDataChunk: LinkDataChunk) => { const {view, target} = this.props; const {link, direction} = linkDataChunk; if (link === ALL_RELATED_ELEMENTS_LINK) { target.addToFilter(); } else { const selectedElement = view.model.getElement(target.id); selectedElement.addToFilter(link, direction); } } render() { const connectionsData = { links: this.links || [], countMap: this.countMap || {}, }; let objectsData: ObjectsData = null; if (this.linkDataChunk && this.objects) { objectsData = { linkDataChunk: this.linkDataChunk, objects: this.objects, }; } const {view, target, suggestProperties} = this.props; return ( ); } } interface ConnectionsMenuMarkupProps { target: Element; connectionsData: { links: FatLinkType[]; countMap: { [linkTypeId: string]: ConnectionCount }; }; objectsData?: ObjectsData; view: DiagramView; state: ProgressState; onExpandLink?: (linkDataChunk: LinkDataChunk) => void; onPressAddSelected?: (selectedObjects: ReactElementModel[]) => void; onMoveToFilter?: (linkDataChunk: LinkDataChunk) => void; propertySuggestionCall?: PropertySuggestionHandler; } interface ConnectionsMenuMarkupState { filterKey?: string; panel?: string; sortMode?: SortMode; } class ConnectionsMenuMarkup extends React.Component { constructor(props: ConnectionsMenuMarkupProps) { super(props); this.state = { filterKey: '', panel: 'connections', sortMode: 'alphabet', }; } private onChangeFilter = (e: React.FormEvent) => { const filterKey = e.currentTarget.value; this.setState({filterKey}); } private getTitle = () => { if (this.props.objectsData && this.state.panel === 'objects') { return 'Objects'; } else if (this.props.connectionsData && this.state.panel === 'connections') { return 'Connections'; } return 'Error'; } private onExpandLink = (linkDataChunk: LinkDataChunk) => { this.setState({ filterKey: '', panel: 'objects' }); this.props.onExpandLink(linkDataChunk); } private onCollapseLink = () => { this.setState({ filterKey: '', panel: 'connections' }); } private getBreadCrumbs = () => { if (this.props.objectsData && this.state.panel === 'objects') { const {link, direction} = this.props.objectsData.linkDataChunk; const localizedText = this.props.view.formatLabel(link.label, link.id); return Connections {'\u00A0' + '/' + '\u00A0'} {localizedText} {direction ? `(${direction})` : null} ; } else { return null; } } private getBody = () => { if (this.props.state === 'error') { return ; } else if (this.props.objectsData && this.state.panel === 'objects') { return ; } else if (this.props.connectionsData && this.state.panel === 'connections') { if (this.props.state === ProgressState.loading) { return ; } return ; } else { return
; } } private onSortChange = (e: React.FormEvent) => { const value = (e.target as HTMLInputElement).value as SortMode; if (this.state.sortMode === value) { return; } this.setState({sortMode: value}); } private renderSortSwitch = (id: string, icon: string, title: string) => { return (
); } private renderSortSwitches = () => { if (this.state.panel !== 'connections' || !this.props.propertySuggestionCall) { return null; } return (
{this.renderSortSwitch('alphabet', 'fa-sort-alpha-asc', 'Sort alphabetically')} {this.renderSortSwitch('smart', 'fa-lightbulb-o', 'Smart sort')}
); } render() { return (
{this.getBreadCrumbs()}
{this.renderSortSwitches()}
{this.getBody()}
); } } interface ConnectionsListProps { id: string; data: { links: FatLinkType[]; countMap: { [linkTypeId: string]: ConnectionCount }; }; view: DiagramView; filterKey: string; onExpandLink?: (linkDataChunk: LinkDataChunk) => void; onMoveToFilter?: (linkDataChunk: LinkDataChunk) => void; propertySuggestionCall?: PropertySuggestionHandler; sortMode: SortMode; } class ConnectionsList extends React.Component }> { constructor(props: ConnectionsListProps) { super(props); this.state = { scores: {} }; this.updateScores(props); } componentWillReceiveProps(newProps: ConnectionsListProps) { this.updateScores(newProps); } private updateScores = (props: ConnectionsListProps) => { if (props.propertySuggestionCall && (props.filterKey || props.sortMode === 'smart')) { const {id, data, view, filterKey} = props; const lang = view.getLanguage(); const token = filterKey.trim(); const properties = data.links.map(l => l.id); props.propertySuggestionCall({elementId: id, token, properties, lang}).then(scores => this.setState({scores}) ); } } private isSmartMode(): boolean { return this.props.sortMode === 'smart' && !this.props.filterKey; } private compareLinks = (a: FatLinkType, b: FatLinkType) => { const {view} = this.props; const aText = view.formatLabel(a.label, a.id); const bText = view.formatLabel(b.label, b.id); return aText.localeCompare(bText); } private compareLinksByWeight = (a: FatLinkType, b: FatLinkType) => { const {view} = this.props; const aText = view.formatLabel(a.label, a.id); const bText = view.formatLabel(b.label, b.id); const aWeight = this.state.scores[a.id] ? this.state.scores[a.id].score : 0; const bWeight = this.state.scores[b.id] ? this.state.scores[b.id].score : 0; return ( aWeight > bWeight ? -1 : aWeight < bWeight ? 1 : aText.localeCompare(bText) ); } private getLinks = () => { const {view, data, filterKey} = this.props; return (data.links || []).filter(link => { const text = view.formatLabel(link.label, link.id).toLowerCase(); return !filterKey || text.indexOf(filterKey.toLowerCase()) >= 0; }) .sort(this.compareLinks); } private getProbableLinks = () => { const isSmartMode = this.isSmartMode(); return (this.props.data.links || []).filter(link => { return this.state.scores[link.id] && (this.state.scores[link.id].score > 0 || isSmartMode); }).sort(this.compareLinksByWeight); } private getViews = (links: FatLinkType[], notSure?: boolean) => { const {view} = this.props; const countMap = this.props.data.countMap || {}; const views: JSX.Element[] = []; const addView = (link: FatLinkType, direction: 'in' | 'out') => { const count = direction === 'in' ? countMap[link.id].inCount : countMap[link.id].outCount; if (count === 0) { return; } const postfix = notSure ? '-probable' : ''; views.push( , ); }; for (const link of links) { addView(link, 'in'); addView(link, 'out'); } return views; } render() { const {view} = this.props; const isSmartMode = this.isSmartMode(); const links = isSmartMode ? [] : this.getLinks(); const probableLinks = this.getProbableLinks().filter(link => links.indexOf(link) === -1); const views = this.getViews(links); const probableViews = this.getViews(probableLinks, true); let viewList: React.ReactElement | React.ReactElement[]; if (views.length === 0 && probableViews.length === 0) { viewList = ; } else { viewList = views; if (views.length > 1 || (isSmartMode && probableViews.length > 1)) { const countMap = this.props.data.countMap || {}; const allRelatedElements = countMap[ALL_RELATED_ELEMENTS_LINK.id]; viewList = [ ,
, ].concat(viewList); } } let probablePart = null; if (probableViews.length !== 0) { probablePart = [ isSmartMode ? null : (
  • Probably, you're looking for..
  • ), probableViews, ]; } return
      {viewList}{probablePart}
    ; } } interface LinkInPopupMenuProps { link: FatLinkType; count: number; direction?: 'in' | 'out'; view: DiagramView; filterKey?: string; onExpandLink?: (linkDataChunk: LinkDataChunk) => void; onMoveToFilter?: (linkDataChunk: LinkDataChunk) => void; probability?: number; } class LinkInPopupMenu extends React.Component { constructor(props: LinkInPopupMenuProps) { super(props); } private onExpandLink = (expectedCount: number, direction?: 'in' | 'out') => { this.props.onExpandLink({ link: this.props.link, direction, expectedCount, }); } private onMoveToFilter = (evt: React.MouseEvent) => { evt.stopPropagation(); this.props.onMoveToFilter({ link: this.props.link, direction: this.props.direction, expectedCount: this.props.count, }); } render() { const {view, link} = this.props; const fullText = view.formatLabel(link.label, link.id); const probability = Math.round(this.props.probability * 100); const textLine = highlightSubstring( fullText + (probability > 0 ? ' (' + probability + '%)' : ''), this.props.filterKey ); const directionName = this.props.direction === 'in' ? 'source' : this.props.direction === 'out' ? 'target' : 'all connected'; return (
  • this.onExpandLink(this.props.count, this.props.direction)}> {this.props.direction === 'in' || this.props.direction === 'out' ?
    {this.props.direction === 'in' &&
    } {this.props.direction === 'out' &&
    }
    : null}
    {textLine}
    {this.props.count <= MAX_LINK_COUNT ? this.props.count : '100+'}
  • ); } } interface ObjectsPanelProps { data: ObjectsData; loading?: boolean; view: DiagramView; filterKey?: string; onPressAddSelected?: (selectedObjects: ReactElementModel[]) => void; onMoveToFilter?: (linkDataChunk: LinkDataChunk) => void; } interface ObjectsPanelState { selection: ReadonlySet; } class ObjectsPanel extends React.Component { constructor(props: ObjectsPanelProps) { super(props); this.state = {selection: new Set()}; } componentWillReceiveProps(nextProps: ObjectsPanelProps) { if (this.props.data.objects.length < nextProps.data.objects.length) { this.setState({selection: new Set()}); } } private onSelectAll = () => { const objects = this.props.data.objects; if (objects.length === 0) { return; } const allSelected = allNonPresentedAreSelected(objects, this.state.selection); const newSelection = allSelected ? new Set() : selectNonPresented(this.props.data.objects); this.updateSelection(newSelection); } private getFilteredObjects(): ReactElementModel[] { if (!this.props.filterKey) { return this.props.data.objects; } const filterKey = this.props.filterKey.toLowerCase(); return this.props.data.objects.filter(element => { const text = this.props.view.formatLabel( element.model.label.values, element.model.id ).toLowerCase(); return text && text.indexOf(filterKey) >= 0; }); } private getItems(list: ReadonlyArray) { const added: { [id: string]: true } = {}; const result: ElementModel[] = []; for (const obj of list) { if (added[obj.model.id]) { continue; } added[obj.model.id] = true; result.push(obj.model); } return result; } private updateSelection = (newSelection: ReadonlySet) => { this.setState({selection: newSelection}); } private counter = (activeObjCount: number) => { const countString = `${activeObjCount}\u00A0of\u00A0${this.props.data.objects.length}`; const wrongNodes = Math.min(MAX_LINK_COUNT, this.props.data.linkDataChunk.expectedCount) - this.props.data.objects.length; const wrongNodesString = Math.abs(wrongNodes) > MAX_LINK_COUNT ? `${MAX_LINK_COUNT}+` : Math.abs(wrongNodes).toString(); const wrongNodesCount = wrongNodes === 0 ? '' : (wrongNodes < 0 ? `\u00A0(${wrongNodesString})` : `\u00A0(${wrongNodesString})`); const wrongNodesTitle = wrongNodes === 0 ? '' : (wrongNodes > 0 ? 'Unavailable nodes' : 'Extra nodes'); return
    {countString} {wrongNodesCount}
    ; } render() { const {onPressAddSelected, filterKey} = this.props; const {selection} = this.state; const objects = this.getFilteredObjects(); const isAllSelected = allNonPresentedAreSelected(objects, selection); const nonPresented = objects.filter(el => !el.presentOnDiagram); const active = nonPresented.filter(el => selection.has(el.model.id)); return
    {( this.props.loading ? : objects.length === 0 ? :
    {this.props.data.linkDataChunk.expectedCount > MAX_LINK_COUNT ? (
    this.props.onMoveToFilter(this.props.data.linkDataChunk)}> The list was truncated, for more data click here to use the filter panel
    ) : null}
    )}
    {this.counter(active.length)}
    ; } } function selectNonPresented(objects: ReadonlyArray) { const selection = new Set(); for (const object of objects) { if (object.presentOnDiagram) { continue; } selection.add(object.model.id); } return selection; } function allNonPresentedAreSelected( objects: ReadonlyArray, selection: ReadonlySet ): boolean { let allSelected = true; for (const object of objects) { if (object.presentOnDiagram) { continue; } allSelected = allSelected && selection.has(object.model.id); } return allSelected; }