import * as React from 'react'; import _ from 'lodash'; import classnames from 'classnames'; import { ThreeBounce } from 'better-react-spinkit'; import ReactResizeDetector from 'react-resize-detector'; import './styles.scss'; import autobind from 'autobind-decorator'; import Spinner from 'react-spinkit'; import LoadingIndicator from '../loadingIndicator/LoadingIndicator'; import { action, autorun, computed, IReactionDisposer, makeObservable, observable, } from 'mobx'; import { ReactChild } from 'react'; import { observer } from 'mobx-react'; import MemoizedHandlerFactory from '../../lib/MemoizedHandlerFactory'; import URL from 'url'; import { DefaultTooltip, getBrowserWindow } from 'cbioportal-frontend-commons'; export interface IMSKTabProps { inactive?: boolean; id: string; linkText: string | JSX.Element; linkTooltip?: string | JSX.Element; activeId?: string; className?: string; hide?: boolean | (() => boolean); datum?: any; anchorStyle?: { [k: string]: string | number | boolean }; anchorClassName?: string; unmountOnHide?: boolean; onTabDidMount?: (tab: HTMLDivElement) => void; onTabUnmount?: (tab: HTMLDivElement) => void; onClickClose?: (tabId: string) => void; pending?: boolean; } @observer export class DeferredRender extends React.Component< { className: string; loadingState?: JSX.Element }, {} > { @observable renderedOnce = false; constructor(props: any) { super(props); makeObservable(this); } render() { if (!this.renderedOnce) { setTimeout(() => (this.renderedOnce = true)); } return (
{this.renderedOnce && this.props.children} {!this.renderedOnce && (this.props.loadingState || null)}
); } } export class MSKTab extends React.Component { public div: HTMLDivElement; componentDidMount() { if (this.props.onTabDidMount) { this.props.onTabDidMount(this.div); } } componentWillUnmount() { if (this.props.onTabUnmount) { this.props.onTabUnmount(this.div); } } @autobind assignRef(div: HTMLDivElement) { this.div = div; } render() { return (
(this.div = div)} className={classnames( { 'msk-tab': true, hiddenByPosition: !!this.props.inactive, }, this.props.className )} > {this.props.children}
); } } interface IMSKTabsProps { className?: string; id?: string; activeTabId?: string; onTabClick?: (tabId: string, datum: any) => void; getPaginationWidth?: () => number; // only used when pagination is true to style arrows arrowStyle?: { [k: string]: string | number | boolean }; tabButtonStyle?: string; unmountOnHide?: boolean; loadingComponent?: JSX.Element; contentWindowExtra?: JSX.Element; hrefRoot?: string; onMount?: () => void; defaultTabId?: string | Boolean; } @observer export class MSKTabs extends React.Component { @observable currentPage: number = 1; @observable pageBreaks: string[] = []; private shownTabs: string[] = []; private navTabsRef: HTMLUListElement; private tabRefs: { id: string; element: HTMLLIElement }[] = []; private tabIdToNavTabWidth: { [tabId: string]: number } = {}; private needToRecomputeNavTabWidths = true; private widthReaction: IReactionDisposer; public static defaultProps: Partial = { unmountOnHide: true, loadingComponent: ( ), }; private tabClickHandlers = MemoizedHandlerFactory( ( e: React.MouseEvent, tabProps: Pick ) => { e.preventDefault(); this.setActiveTab(tabProps.id, tabProps.datum); } ); constructor(props: IMSKTabsProps) { super(props); makeObservable(this); props.onMount && props.onMount(); } private cloneTab( tab: React.ReactElement, inactive: boolean, loading?: boolean ): React.ReactElement { if (loading) { return React.cloneElement( tab, { inactive } as Partial, this.props.loadingComponent! ); } else { return React.cloneElement(tab, { inactive } as Partial< IMSKTabProps >); } } setActiveTab(id: string, datum?: any) { this.props.onTabClick && this.props.onTabClick(id, datum); } navTabsRefHandler(ul: HTMLUListElement) { this.navTabsRef = ul; } tabRefHandler(id: string, li: HTMLLIElement) { if (id && li) { this.tabRefs.push({ id, element: li }); } } @action nextPage() { this.currentPage += 1; } @action prevPage() { this.currentPage -= 1; } render() { if (this.props.children && React.Children.count(this.props.children)) { let children = this.props.children as React.ReactElement< IMSKTabProps >[]; const toArrayedChildren: ( | ReactChild | {} )[] = React.Children.toArray(children); const targetTabId = (() => { if ( this.props.activeTabId && _.some( toArrayedChildren, (child: React.ReactElement) => child.props.id === this.props.activeTabId ) ) { return this.props.activeTabId; } else { return this.props.defaultTabId === false ? undefined : (toArrayedChildren[0] as React.ReactElement< IMSKTabProps >).props.id; } })(); let arr: React.ReactElement[] = []; // NOTE: we have to clone tab in order to manipulate // it's props, which are immutable for an instance of a child element // in this case we are to manipulate it's "inactive" prop arr = _.reduce( toArrayedChildren, ( memo: React.ReactElement[], child: React.ReactElement ) => { if (!child.props.hide) { if (child.props.id === targetTabId) { this.shownTabs.push(child.props.id); memo.push(this.cloneTab(child, false)); } else if ( (child.props.unmountOnHide === false || (child.props.unmountOnHide === undefined && this.props.unmountOnHide === false)) && _.includes(this.shownTabs, child.props.id) ) { // if we're NOT unmounting it and the tab has been shown and it's not loading, include it memo.push(this.cloneTab(child, true)); } } return memo; }, [] ); return (
{this.navTabs(children, targetTabId)} } > {this.props.contentWindowExtra} {arr}
); } else { return null; } } protected navTabs( children: React.ReactElement[], effectiveActiveTab: string | undefined ) { // restart the tab refs before each tab rendering this.tabRefs = []; // if pagination is disabled, pages.length and pagesCount will be always 1 const pages = this.tabPages(children, effectiveActiveTab); const pageCount = this.pageBreaks.length + 1; const prev = this.currentPage > 1 ? (
  • ) : null; const next = this.currentPage < pageCount ? (
  • ) : null; const navButtonStyle: string = this.props.tabButtonStyle || 'tabs'; return (
      {prev} {pages[this.currentPage - 1]} {next}
    ); } private getTabHref(tabId: string) { if (this.props.hrefRoot) { return URL.format({ pathname: `${this.props.hrefRoot}/${tabId}`, search: getBrowserWindow().location.search, hash: getBrowserWindow().location.hash, }); } else { // will disable without losing styles return undefined; } } protected tabPages( children: React.ReactElement[], effectiveActiveTab: string | undefined ): JSX.Element[][] { const pages: JSX.Element[][] = [[]]; let currentPage = 1; React.Children.forEach( children, (tab: React.ReactElement) => { // tab could be null/undefined here if getTab() happens to return null // instead of using hide prop if (!tab) return; const isActive = effectiveActiveTab === tab.props.id; // if we are not currently ROUTED to tab // then hide it as indicated // if we ARE ROUTED to it, we want to ignore if (!isActive) { if (tab.props.hide) { return; } if (tab.props.pending) { return; } } let activeClass = effectiveActiveTab === tab.props.id ? 'active' : ''; // find out if we need to add another page if ( this.props.getPaginationWidth && this.pageBreaks.length > 0 && this.pageBreaks[currentPage - 1] === tab.props.id ) { currentPage++; pages[currentPage - 1] = []; } let closeButton: JSX.Element | null = null; if (tab.props.onClickClose) { const onClickClose = tab.props.onClickClose; closeButton = (
    { e.stopPropagation(); onClickClose(tab.props.id); }} >
    ); } const href = this.getTabHref(tab.props.id); const linkContent = tab.props.linkTooltip ? ( {tab.props.linkText} ) : ( tab.props.linkText ); pages[currentPage - 1].push(
  • {linkContent} {closeButton}
  • ); } ); return pages; } static getVisibleTabIds(tabs: MSKTab[]) { return React.Children.map(tabs, tab => tab && !tab.props.hide ? tab.props.id : null ).filter(x => x !== null); } componentWillReceiveProps(nextProps: Readonly): void { if ( !_.isEqual( MSKTabs.getVisibleTabIds((nextProps as any).children), MSKTabs.getVisibleTabIds((this.props as any).children) ) ) { // visible tabs have changed -> need to recompute nav tab widths, which will also initiate repagination this.needToRecomputeNavTabWidths = true; } } componentDidMount() { this.computeNavTabWidths(); this.needToRecomputeNavTabWidths = false; this.widthReaction = autorun(() => { if (this.props.getPaginationWidth) { this.props.getPaginationWidth(); // react to changes in pagination width this.initPaging(); } }); } componentWillUnmount() { this.widthReaction(); } componentDidUpdate() { // recompute pagination and paginate to current tab if (this.needToRecomputeNavTabWidths) { this.computeNavTabWidths(); this.needToRecomputeNavTabWidths = false; this.initPaging(); } } computeNavTabWidths() { //this.tabIdToNavTabWidth = {}; _.each(this.tabRefs, ref => { this.tabIdToNavTabWidth[ref.id] = ref.element.offsetWidth; }); (window as any).test = this.tabIdToNavTabWidth; } @action.bound initPaging() { if (this.props.getPaginationWidth) { // find page breaks: depends on width of the container this.pageBreaks = this.findPageBreaks(); // find current page: depends on active tab id this.currentPage = this.findCurrentPage(this.pageBreaks); } } findCurrentPage(pageBreaks: string[]) { let currentPage = 1; let found = false; if (this.props.activeTabId && pageBreaks.length > 0) { _.each(this.tabRefs, ref => { // we reached a page break before reaching the active tab id. // increment current page if (ref.id === pageBreaks[currentPage - 1]) { currentPage++; } // we reached the active tab id within current page. // break the each loop, and return current page. if (ref.id === this.props.activeTabId) { found = true; return false; } }); } // in case active tab id is not valid, default to first page return found ? currentPage : 1; } findPageBreaks() { const pageBreaks: string[] = []; if (this.needToRecomputeNavTabWidths) { return []; // no page breaks if we still need to recompute widths - need to render them all at once to get width } const containerWidth = (this.props.getPaginationWidth && this.props.getPaginationWidth()) || 0; // do not attempt paging if container width is zero if (containerWidth > 0) { let width = 0; React.Children.forEach( this.props.children, (tab: MSKTab | null, index: number) => { if (!tab || !(tab.props.id in this.tabIdToNavTabWidth)) { // skip a null child or a tab that hasnt been rendered yet return; } width += this.tabIdToNavTabWidth[tab.props.id]; if (index === 0) { // page break not allowed at first tab return; } const padding = 50; // add a page break, and reset the width for the next page if (width > containerWidth - padding) { pageBreaks.push(tab.props.id); width = this.tabIdToNavTabWidth[tab.props.id]; } } ); } return pageBreaks; } }