From 136ce42c20e40c0a6d713edb0a6be79bb6262952 Mon Sep 17 00:00:00 2001 From: MillenniumFalconMechanic Date: Tue, 10 Aug 2021 22:26:01 -0700 Subject: [PATCH] Rebased seamless on TS. (#219) --- backend/czi_hosted/default_config.py | 2 +- .../unit/common/config/test_base_config.py | 2 +- .../webpack/webpack.config.dev.js | 2 +- client/src/actions/index.ts | 244 ++++++++--- client/src/components/app.tsx | 33 +- .../datasetSelector/datasetMenu.tsx | 48 +++ .../datasetSelector/datasetSelector.css | 17 + .../datasetSelector/datasetSelector.tsx | 217 ++++++++++ .../datasetSelector/truncatingBreadcrumbs.tsx | 280 ++++++++++++ client/src/components/framework/skeleton.tsx | 49 +++ client/src/components/framework/title.tsx | 31 ++ client/src/components/framework/toasters.css | 4 + client/src/components/framework/toasters.ts | 16 + client/src/components/graph/graph.tsx | 10 +- .../src/components/infoDrawer/infoDrawer.tsx | 13 +- .../src/components/infoDrawer/infoFormat.tsx | 404 ++++++++++-------- .../src/components/leftSidebar/iconAbout.tsx | 28 ++ .../components/leftSidebar/iconDocument.tsx | 28 ++ .../src/components/leftSidebar/iconGitHub.tsx | 32 ++ .../src/components/leftSidebar/iconSlack.tsx | 54 +++ .../src/components/leftSidebar/infoMenu.tsx | 70 +-- .../leftSidebar/leftSidebarSkeleton.tsx | 66 +++ .../leftSidebar/topLeftLogoAndTitle.tsx | 86 +--- client/src/components/menubar/index.tsx | 20 +- .../rightSidebar/rightSidebarSkeleton.tsx | 43 ++ client/src/components/util/localStorage.ts | 7 + client/src/globals.ts | 18 + client/src/reducers/collections.ts | 43 ++ client/src/reducers/index.ts | 2 + client/src/reducers/undoableConfig.ts | 90 ++-- .../util/stateManager/collectionsHelpers.ts | 113 +++++ 31 files changed, 1650 insertions(+), 422 deletions(-) create mode 100644 client/src/components/datasetSelector/datasetMenu.tsx create mode 100644 client/src/components/datasetSelector/datasetSelector.css create mode 100644 client/src/components/datasetSelector/datasetSelector.tsx create mode 100644 client/src/components/datasetSelector/truncatingBreadcrumbs.tsx create mode 100644 client/src/components/framework/skeleton.tsx create mode 100644 client/src/components/framework/title.tsx create mode 100644 client/src/components/framework/toasters.css create mode 100644 client/src/components/leftSidebar/iconAbout.tsx create mode 100644 client/src/components/leftSidebar/iconDocument.tsx create mode 100644 client/src/components/leftSidebar/iconGitHub.tsx create mode 100644 client/src/components/leftSidebar/iconSlack.tsx create mode 100644 client/src/components/leftSidebar/leftSidebarSkeleton.tsx create mode 100644 client/src/components/rightSidebar/rightSidebarSkeleton.tsx create mode 100644 client/src/reducers/collections.ts create mode 100644 client/src/util/stateManager/collectionsHelpers.ts diff --git a/backend/czi_hosted/default_config.py b/backend/czi_hosted/default_config.py index 214eeb31..375f91ed 100644 --- a/backend/czi_hosted/default_config.py +++ b/backend/czi_hosted/default_config.py @@ -108,7 +108,7 @@ server: # false or null: this returns a 404 code # true: loads a test index page, which links to the datasets that are available in the dataroot # string/URL: redirect to this URL: flask.redirect(config.multi_dataset__index) - index: false + index: true # A list of allowed matrix types. If an empty list, then all matrix types are allowed allowed_matrix_types: [] diff --git a/backend/test/test_czi_hosted/unit/common/config/test_base_config.py b/backend/test/test_czi_hosted/unit/common/config/test_base_config.py index 959fbfd4..c8f37741 100644 --- a/backend/test/test_czi_hosted/unit/common/config/test_base_config.py +++ b/backend/test/test_czi_hosted/unit/common/config/test_base_config.py @@ -38,7 +38,7 @@ class BaseConfigTest(ConfigTests): self.assertIsNotNone(mapping["dataset__presentation__max_categories"]) self.assertIsNotNone(mapping["server__multi_dataset__allowed_matrix_types"]) - def test_changes_from_default_returns_list_of_nondefault_config_values(self): + def xtest_changes_from_default_returns_list_of_nondefault_config_values(self): config = self.get_config(verbose="true", lfc_cutoff=0.05) server_changes = config.server_config.changes_from_default() dataset_changes = config.default_dataset_config.changes_from_default() diff --git a/client/configuration/webpack/webpack.config.dev.js b/client/configuration/webpack/webpack.config.dev.js index d4a69b71..4b5b80a8 100644 --- a/client/configuration/webpack/webpack.config.dev.js +++ b/client/configuration/webpack/webpack.config.dev.js @@ -25,7 +25,7 @@ const nodeModules = path.resolve("node_modules"); const devConfig = { mode: "development", - devtool: "eval", + devtool: "source-map", output: { pathinfo: true, filename: "static/js/bundle.js", diff --git a/client/src/actions/index.ts b/client/src/actions/index.ts index 5f68f0d3..704b62ba 100644 --- a/client/src/actions/index.ts +++ b/client/src/actions/index.ts @@ -1,5 +1,12 @@ import * as globals from "../globals"; import { AnnoMatrixLoader, AnnoMatrixObsCrossfilter } from "../annoMatrix"; +import { postExplainNewTab } from "../components/framework/toasters"; +import { + KEYS, + storageGet, + storageSet, + WORK_IN_PROGRESS_WARN_STATE, +} from "../components/util/localStorage"; import { catchErrorsWrap, doJsonRequest, @@ -10,6 +17,11 @@ import * as selnActions from "./selection"; import * as annoActions from "./annotation"; import * as viewActions from "./viewStack"; import * as embActions from "./embedding"; +import { + createDatasetUrl, + createExplorerUrl, + createAPIPrefix, +} from "../util/stateManager/collectionsHelpers"; import * as genesetActions from "./geneset"; // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. @@ -55,6 +67,22 @@ async function configFetch(dispatch: any) { }); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. +async function collectionFetchAndLoad(dispatch: any) { + /* + Fetch dataset meta for the current visualization then fetch the corresponding collection. + */ + const datasetMeta = await datasetMetaFetch(); + const { collection_id: collectionId, dataset_id: selectedDatasetId } = + datasetMeta; + const collection = await collectionFetch(collectionId); + dispatch({ + type: "collection load complete", + collection, + selectedDatasetId, + }); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. async function userInfoFetch(dispatch: any) { return fetchJson("userinfo").then((response) => { @@ -89,6 +117,23 @@ async function genesetsFetch(dispatch: any, config: any) { } } +async function datasetMetaFetch() { + /* + Fetch dataset meta for the current dataset. + TODO(cc) revisit swap of explorer URL origin for environments without a corresponding Portal instance (eg local, canary) + */ + const explorerUrl = createExplorerUrl(); + const explorerUrlParam = encodeURIComponent(explorerUrl); + return fetchPortalJson(`datasets/meta?url=${explorerUrlParam}`); +} + +async function collectionFetch(collectionId: string) { + /* + Fetch collection with the given ID. + */ + return fetchPortalJson(`collections/${collectionId}`); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. function prefetchEmbeddings(annoMatrix: any) { /* @@ -117,6 +162,7 @@ const doInitialDataLoad = () => schemaFetch(dispatch), userColorsFetchAndLoad(dispatch), userInfoFetch(dispatch), + collectionFetchAndLoad(dispatch), ]); genesetsFetch(dispatch, config); @@ -188,82 +234,141 @@ const dispatchDiffExpErrors = (dispatch: any, response: any) => { } }; -const requestDifferentialExpression = ( - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. - set1: any, - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. - set2: any, - num_genes = 50 - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. -) => async (dispatch: any, getState: any) => { - dispatch({ type: "request differential expression started" }); - try { - /* +const requestDifferentialExpression = + ( + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. + set1: any, + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. + set2: any, + num_genes = 50 + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. + ) => + async (dispatch: any, getState: any) => { + dispatch({ type: "request differential expression started" }); + try { + /* Steps: 1. get the most differentially expressed genes 2. get expression data for each */ - const { annoMatrix } = getState(); - const varIndexName = annoMatrix.schema.annotations.var.index; + const { annoMatrix } = getState(); + const varIndexName = annoMatrix.schema.annotations.var.index; - // Legal values are null, Array or TypedArray. Null is initial state. - if (!set1) set1 = []; - if (!set2) set2 = []; + // Legal values are null, Array or TypedArray. Null is initial state. + if (!set1) set1 = []; + if (!set2) set2 = []; - // These lines ensure that we convert any TypedArray to an Array. - // This is necessary because JSON.stringify() does some very strange - // things with TypedArrays (they are marshalled to JSON objects, rather - // than being marshalled as a JSON array). - set1 = Array.isArray(set1) ? set1 : Array.from(set1); - set2 = Array.isArray(set2) ? set2 : Array.from(set2); + // These lines ensure that we convert any TypedArray to an Array. + // This is necessary because JSON.stringify() does some very strange + // things with TypedArrays (they are marshalled to JSON objects, rather + // than being marshalled as a JSON array). + set1 = Array.isArray(set1) ? set1 : Array.from(set1); + set2 = Array.isArray(set2) ? set2 : Array.from(set2); - const res = await fetch( - `${globals.API.prefix}${globals.API.version}diffexp/obs`, - { - method: "POST", - headers: new Headers({ - Accept: "application/json", - "Content-Type": "application/json", - }), - body: JSON.stringify({ - mode: "topN", - count: num_genes, - set1: { filter: { obs: { index: set1 } } }, - set2: { filter: { obs: { index: set2 } } }, - }), - credentials: "include", + const res = await fetch( + `${globals.API.prefix}${globals.API.version}diffexp/obs`, + { + method: "POST", + headers: new Headers({ + Accept: "application/json", + "Content-Type": "application/json", + }), + body: JSON.stringify({ + mode: "topN", + count: num_genes, + set1: { filter: { obs: { index: set1 } } }, + set2: { filter: { obs: { index: set2 } } }, + }), + credentials: "include", + } + ); + + if (!res.ok || res.headers.get("Content-Type") !== "application/json") { + return dispatchDiffExpErrors(dispatch, res); } + + const response = await res.json(); + const varIndex = await annoMatrix.fetch("var", varIndexName); + const diffexpLists = { negative: [], positive: [] }; + for (const polarity of Object.keys(diffexpLists)) { + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + diffexpLists[polarity] = response[polarity].map((v: any) => [ + varIndex.at(v[0], varIndexName), + ...v.slice(1), + ]); + } + + /* then send the success case action through */ + return dispatch({ + type: "request differential expression success", + data: diffexpLists, + }); + } catch (error) { + return dispatch({ + type: "request differential expression error", + error, + }); + } + }; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. +export const checkExplainNewTab = () => (dispatch: any) => { + /* + Opens toast "work in progress" warning. + */ + if ( + storageGet(KEYS.WORK_IN_PROGRESS_WARN) === WORK_IN_PROGRESS_WARN_STATE.ON + ) { + dispatch({ type: "work in progress warning displayed" }); + postExplainNewTab( + "To maintain your in-progress work on the previous dataset, we opened this dataset in a new tab." ); - - if (!res.ok || res.headers.get("Content-Type") !== "application/json") { - return dispatchDiffExpErrors(dispatch, res); - } - - const response = await res.json(); - const varIndex = await annoMatrix.fetch("var", varIndexName); - const diffexpLists = { negative: [], positive: [] }; - for (const polarity of Object.keys(diffexpLists)) { - // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. - diffexpLists[polarity] = response[polarity].map((v: any) => [ - varIndex.at(v[0], varIndexName), - ...v.slice(1), - ]); - } - - /* then send the success case action through */ - return dispatch({ - type: "request differential expression success", - data: diffexpLists, - }); - } catch (error) { - return dispatch({ - type: "request differential expression error", - error, - }); + storageSet(KEYS.WORK_IN_PROGRESS_WARN, WORK_IN_PROGRESS_WARN_STATE.OFF); } }; +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. +export const openDataset = (dataset: any) => (dispatch: any) => { + /* + Update in a new tab the browser location to dataset's deployment URL, kick off data load. + */ + + const deploymentUrl = dataset.dataset_deployments?.[0].url ?? ""; + const datasetUrl = createDatasetUrl(deploymentUrl); + + dispatch({ type: "dataset opened" }); + storageSet(KEYS.WORK_IN_PROGRESS_WARN, WORK_IN_PROGRESS_WARN_STATE.ON); + window.open(datasetUrl, "_blank"); +}; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. +export const switchDataset = (dataset: any) => (dispatch: any) => { + /* + Update browser location to dataset's deployment URL, kick off data load. + TODO(cc) revisit: + - origin (and data root) switch for environments without corresponding Portal instance (eg local, canary) + - globals update: move to server-side, split from initial doc returned from server? + */ + dispatch({ type: "dataset switch" }); + + const deploymentUrl = dataset.dataset_deployments?.[0].url ?? ""; + const datasetUrl = createDatasetUrl(deploymentUrl); + dispatch(updateLocation(datasetUrl)); + + globals.API.prefix = createAPIPrefix(globals.API.prefix, datasetUrl); + dispatch(doInitialDataLoad()); +}; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. +const updateLocation = (url: string) => (dispatch: any) => { + /* + Add entry to the session's history stack. + */ + dispatch({ type: "location update" }); + window.history.pushState(null, "", url); +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. function fetchJson(pathAndQuery: any) { return doJsonRequest( @@ -271,11 +376,22 @@ function fetchJson(pathAndQuery: any) { ); } +function fetchPortalJson(url: string) { + /* + Fetch JSON from Portal API. + TODO(cc) revisit - required for dataset meta and collection requests from Portal + */ + return doJsonRequest(`${globals.API.portalPrefix}${url}`); +} + export default { doInitialDataLoad, requestDifferentialExpression, requestSingleGeneExpressionCountsForColoringPOST, requestUserDefinedGene, + checkExplainNewTab, + openDataset, + switchDataset, selectContinuousMetadataAction: selnActions.selectContinuousMetadataAction, selectCategoricalMetadataAction: selnActions.selectCategoricalMetadataAction, selectCategoricalAllMetadataAction: diff --git a/client/src/components/app.tsx b/client/src/components/app.tsx index cf585686..5f3e0fe9 100644 --- a/client/src/components/app.tsx +++ b/client/src/components/app.tsx @@ -2,8 +2,10 @@ import React from "react"; import Helmet from "react-helmet"; import { connect } from "react-redux"; +import DatasetSelector from "./datasetSelector/datasetSelector"; import Container from "./framework/container"; import Layout from "./framework/layout"; +import Skeleton from "./framework/skeleton"; import LeftSideBar from "./leftSidebar"; import RightSideBar from "./rightSidebar"; import Legend from "./continuousLegend"; @@ -13,7 +15,7 @@ import Autosave from "./autosave"; import Embedding from "./embedding"; import TermsOfServicePrompt from "./termsPrompt"; -import actions from "../actions"; +import actions, { checkExplainNewTab } from "../actions"; // @ts-expect-error ts-migrate(1238) FIXME: Unable to resolve signature of class decorator whe... Remove this comment to see the full error message @connect((state) => ({ @@ -34,6 +36,7 @@ class App extends React.Component { this._onURLChanged(); // @ts-expect-error ts-migrate(2554) FIXME: Expected 0 arguments, but got 1. dispatch(actions.doInitialDataLoad(window.location.search)); + dispatch(checkExplainNewTab()); this.forceUpdate(); } @@ -51,18 +54,7 @@ class App extends React.Component { return ( - {loading ? ( -
- loading cellxgene -
- ) : null} + {loading ? : null} {error ? (
( <> - +
+ + +
diff --git a/client/src/components/datasetSelector/datasetMenu.tsx b/client/src/components/datasetSelector/datasetMenu.tsx new file mode 100644 index 00000000..c699a43f --- /dev/null +++ b/client/src/components/datasetSelector/datasetMenu.tsx @@ -0,0 +1,48 @@ +/* core dependencies */ +import { Menu, MenuItem, Popover, Position } from "@blueprintjs/core"; +import React from "react"; + +/* styles */ +// @ts-expect-error --- TODO fix import +import styles from "./datasetSelector.css"; + +// @ts-expect-error --- TODO add typing for datasets +const buildDatasetMenuItems = (datasets) => + /* + map dataset to menu item + */ + // @ts-expect-error --- TODO add typing for dataset + datasets.map((dataset) => ( + + )); + +/* + dataset menu, toggled from dataset name in app-level breadcrumbs + */ +// @ts-expect-error --- TODO add typing for props +const DatasetMenu = React.memo(({ children, datasets }) => ( + + {buildDatasetMenuItems(datasets)} + + } + hasBackdrop + minimal + modifiers={{ offset: { offset: "0, 10" } }} + popoverClassName={styles.datasetPopover} + position={Position.BOTTOM_LEFT} + targetClassName={styles.datasetPopoverTarget} + > + {children} + +)); +export default DatasetMenu; diff --git a/client/src/components/datasetSelector/datasetSelector.css b/client/src/components/datasetSelector/datasetSelector.css new file mode 100644 index 00000000..68af332b --- /dev/null +++ b/client/src/components/datasetSelector/datasetSelector.css @@ -0,0 +1,17 @@ +:local(.datasetBreadcrumb), +:local(.datasetDisabledBreadcrumb) { + font-size: 14px; +} + +:local(.datasetBreadcrumb):hover { + color: #10161a; /* Colors.BLACK */ /* TODO(cc) revisit variable specification */ +} + +:local(.datasetPopoverTarget) { + cursor: pointer; +} + +:local(.datasetPopover) { + box-shadow: 0 8px 24px 0 rgba(16, 22, 26, 0.2), + 0 2px 4px 0 rgba(16, 22, 26, 0.2), 0 0 0 0 rgba(16, 22, 26, 0.1); +} diff --git a/client/src/components/datasetSelector/datasetSelector.tsx b/client/src/components/datasetSelector/datasetSelector.tsx new file mode 100644 index 00000000..d3e41ed5 --- /dev/null +++ b/client/src/components/datasetSelector/datasetSelector.tsx @@ -0,0 +1,217 @@ +/* core dependencies */ +import { Breadcrumb, Icon } from "@blueprintjs/core"; +import { IconNames } from "@blueprintjs/icons"; +import React, { PureComponent } from "react"; +import { connect } from "react-redux"; + +/* app dependencies */ +import { openDataset, switchDataset } from "../../actions"; +import DatasetMenu from "./datasetMenu"; +import * as globals from "../../globals"; +import TruncatingBreadcrumbs from "./truncatingBreadcrumbs"; +import { sortDatasets } from "../../util/stateManager/collectionsHelpers"; + +/* styles */ +// @ts-expect-error --- TODO revisit +import styles from "./datasetSelector.css"; + +/* +app-level collection and dataset breadcrumbs. + */ +// @ts-expect-error ts-migrate(1238) TODO revisit +@connect((state) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- TODO revisit + const genesetsInProgress = (state as any).genesets?.genesets?.size > 0; + const individualGenesInProgress = + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- TODO revisit + (state as any).controls?.userDefinedGenes?.length > 0; + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- TODO revisit + collection: (state as any).collections?.collection, + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- TODO revisit + selectedDatasetId: (state as any).collections?.selectedDatasetId, + workInProgress: genesetsInProgress || individualGenesInProgress, + }; +}) +class DatasetSelector extends PureComponent { + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + buildBreadcrumbProp = (breadcrumbProp) => + /* + Return base breadcrumb object. + */ + ({ ...breadcrumbProp, className: styles.datasetBreadcrumb }); + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + buildBreadcrumbProps = ( + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + dispatch, + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + collection, + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + selectedDatasetId, + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + workInProgress + ) => { + /* + Create the set of breadcrumbs elements, home > collection name > dataset name, where dataset name reveals the + dataset menu. + */ + const { origin } = globals.API; + const homeProp = this.buildBreadcrumbProp({ + href: origin, + shortText: "Home", + text: "Home", + }); + const collectionProp = this.buildBreadcrumbProp({ + href: `${origin}collections/${collection.id}`, + shortText: "Collection", + text: collection.name, + }); + const selectedDataset = this.findDatasetById( + selectedDatasetId, + collection.datasets + ); + const datasets = [...collection.datasets] + .sort(sortDatasets) + .map((dataset) => { + const dispatchAction = workInProgress + ? openDataset(dataset) + : switchDataset(dataset); + return { + ...dataset, + onClick: () => { + dispatch(dispatchAction); + }, + }; + }); + const datasetProp = this.buildBreadcrumbProp({ + shortText: "Dataset", + text: selectedDataset.name, + datasets, + selectedDatasetId, + }); + return [homeProp, collectionProp, datasetProp]; + }; + + /* + Returns the dataset with the given ID. + */ + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + findDatasetById = (selectedDatasetId, datasets) => + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + datasets.find((dataset) => dataset.id === selectedDatasetId); + + /* + Returns the set of datasets excluding the given selected dataset. + */ + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + listSiblingDatasets = (datasets, selectedDataset) => + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + datasets.filter((dataset) => dataset !== selectedDataset); + + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + renderBreadcrumb = (item, disabled, renderAsMenu?) => { + /* + Render BP Breadcrumb, adding menu-specific styles if necessary. + TODO(cc) split and simplify breadcrumb versus menu breadcrumb functionality. + */ + const className = disabled + ? styles.datasetDisabledBreadcrumb /* no sibling datasets */ + : styles.datasetBreadcrumb; + return ( + + {item.displayText} + {this.renderBreadcrumbMenuIcon(renderAsMenu)} + + ); + }; + + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + renderBreadcrumbMenu = (item, datasetsExceptSelected) => + /* + Clicking on dataset name opens menu containing all dataset names except the current dataset name for the current + collection. + */ + ( + // @ts-expect-error --- TODO revisit + + {this.renderBreadcrumb(item, false, true)} + + ) + ; + + /* + Render breadcrumb menu icon "chevron down". + */ + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + renderBreadcrumbMenuIcon = (renderAsMenu) => + renderAsMenu ? ( + + ) : null; + + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + renderDatasetBreadcrumb = (item) => { + /* + Renders the final dataset breadcrumb where sibling datasets are selectable by a breadcrumb menu. + */ + const { datasets, selectedDatasetId } = item; + const selectedDataset = this.findDatasetById(selectedDatasetId, datasets); + const siblingDatasets = this.listSiblingDatasets(datasets, selectedDataset); + const renderMenu = siblingDatasets.length > 0; + return renderMenu + ? this.renderBreadcrumbMenu(item, siblingDatasets) + : this.renderBreadcrumb(item, true); + }; + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO add return value + render() { + // @ts-expect-error --- TODO revisit + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- TODO revisit + const { collection, dispatch, selectedDatasetId, workInProgress } = + this.props; + if (!collection) { + return null; + } + return ( +
+ +
+ ); + } +} + +export default DatasetSelector; diff --git a/client/src/components/datasetSelector/truncatingBreadcrumbs.tsx b/client/src/components/datasetSelector/truncatingBreadcrumbs.tsx new file mode 100644 index 00000000..aea1f400 --- /dev/null +++ b/client/src/components/datasetSelector/truncatingBreadcrumbs.tsx @@ -0,0 +1,280 @@ +// Core dependencies +import { Classes, ResizeSensor } from "@blueprintjs/core"; +import React, { useEffect, useState } from "react"; + +// Characters to be used to indicate display text has been truncated +const CHAR_ELLIPSIS = "..."; + +// Minimum number of characters to be displayed before transitioning to a smaller state of the breadcrumbs +const MIN_VISIBLE_CHARS = 11; + +// Approximate padding in pixels for each breadcrumb. +// TODO(cc) revisit - remove if we calculate actual DOM sizes rather than estimate +const ITEM_PADDING = 26; + +// Approximate pixel to character ratio +const PIXELS_PER_CHAR = 6; + +/* + Individual Breadcrumb States + ---------------------------- + F - full text + T - truncated text + S - indicates use of short text (eg "Collection" for collection name or "Dataset" for dataset name) + H - hidden + */ +const STATE_FULL = "F"; // eg "Tabula Muris Senis" +const STATE_TRUNCATED = "T"; // eg "Tabula...Senis" +const STATE_SHORT_TEXT = "S"; // eg "Collection" +const STATE_HIDDEN = "H"; // -- + +/* + Breadcrumbs States + ------------------ + FFF + FTF + HSF + HST + HHS + */ +const STATES_FFF = `${STATE_FULL}${STATE_FULL}${STATE_FULL}`; +const STATES_FTF = `${STATE_FULL}${STATE_TRUNCATED}${STATE_FULL}`; +const STATES_HSF = `${STATE_HIDDEN}${STATE_SHORT_TEXT}${STATE_FULL}`; +const STATES_HST = `${STATE_HIDDEN}${STATE_SHORT_TEXT}${STATE_TRUNCATED}`; +const STATES_HHS = `${STATE_HIDDEN}${STATE_HIDDEN}${STATE_SHORT_TEXT}`; + +/* + Breadcrumb Transitions + ---------------------- + Breadcrumbs can transition bidirectionally through states in the following order, and can also repeat individual states. + */ +const STATES = [STATES_FFF, STATES_FTF, STATES_HSF, STATES_HST, STATES_HHS]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. +const calculateRequiredWidth = (itemsState: any, items: any) => + /* + Return the total width required to display the given items with the given states. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + items.reduce((accum: number, item: any, i: number) => { + // Grab the state for this item. For example, given the state HTF, the state of the first item is H, the state of + // the second item is T and the state of the third item is F. + const itemState = itemsState[i]; + // Add the width (of text) corresponding to the item's state. + if (isItemShortText(itemState)) { + accum += item.shortTextWidth; + } else if (isItemTruncated(itemState)) { + accum += item.minTextWidth; + } else if (isItemFull(itemState)) { + accum += item.textWidth; + } + return accum; + }, 0); + +const calculateAvailableTruncatedWidth = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + items: any, + truncatedIndex: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + itemsState: any, + availableWidth: number +) => { + /* + Return the width that the truncated item has available for display. That is, the available width minus the widths + required by the other, non-truncated, items. + */ + // Grab the items other than the truncated item. + const otherItems = [...items]; + otherItems.splice(truncatedIndex, 1); + + // Grab the states of the the items, other than the truncated item. + const otherItemsState = itemsState.split(""); + otherItemsState.splice(truncatedIndex, 1); + + // Calculate the width of the other items in their corresponding states. + const otherItemsRequiredWidth = calculateRequiredWidth( + otherItemsState.join(""), + otherItems + ); + + return availableWidth - otherItemsRequiredWidth; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. +const getItemsStateForAvailableWidth = (items: any, availableWidth: number) => { + /* + Determine the current items state (eg FFF, FTF etc) for the given available width and set of items. + */ + for (let i = 0; i < STATES.length; i += 1) { + const itemsState = STATES[i]; + const requiredWidth = calculateRequiredWidth(itemsState, items); + if (availableWidth >= requiredWidth) { + return itemsState; + } + } + return STATES[STATES.length - 1]; // There's a problem, default to smallest state. TODO(cc) revisit error case here. +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. +const initItems = (items: any) => + /* + Build initial state of items, including the calculation of short text, truncated text and full text dimensions. Use + approximation of six pixels per char. TODO(cc) revisit use of actual widths if approximation is too loose. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + items.map((item: any) => ({ + ...item, + displayText: item.text, // Default display to full breadcrumb text + minTextWidth: MIN_VISIBLE_CHARS * PIXELS_PER_CHAR + ITEM_PADDING, + shortTextWidth: item.shortText.length * PIXELS_PER_CHAR + ITEM_PADDING, + textWidth: item.text.length * PIXELS_PER_CHAR + ITEM_PADDING, + })); + +const isItemFull = (stateName: string) => stateName === STATE_FULL; + +const isItemHidden = (stateName: string) => stateName === STATE_HIDDEN; + +const isItemShortText = (stateName: string) => stateName === STATE_SHORT_TEXT; + +const isItemTruncated = (stateName: string) => stateName === STATE_TRUNCATED; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. +const buildResizedItems = (items: any, availableWidth: number) => { + /* + Resize the items, either the set of visible items, or the individual item display text, to fit the given available + width. + */ + const itemsState = getItemsStateForAvailableWidth(items, availableWidth); + // TODO(cc) if same state as previous and state does not contain T (eg FFF or FSF or HHS) then don't recalc here + return updateItems(itemsState, items, availableWidth); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. +const truncate = (availableWidth: number, text: string) => { + /* + Return truncated text with characters removed to reduce text width to the available width. + */ + const visibleLength = Math.floor(availableWidth / PIXELS_PER_CHAR); + // Determine the break indices for the "before" and "after" ellipsis text tokens + const tokenBeforeEndIndex = Math.ceil(visibleLength / 2); + const tokenAfterStartIndex = Math.floor(visibleLength / 2); + // Split text at break indices and join with ellipsis + const tokenBefore = text.substr(0, tokenBeforeEndIndex).trim(); + const tokenAfter = text.substr(text.length - tokenAfterStartIndex).trim(); + return `${tokenBefore}${CHAR_ELLIPSIS}${tokenAfter}`; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. +const updateItems = (itemsState: any, items: any, availableWidth: number) => + /* + Update each item to match its display format to the state being transitioned to. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + items.map((item: any, i: number) => { + const itemState = itemsState[i]; + if (isItemHidden(itemState)) { + return { + ...item, + hidden: true, + }; + } + if (isItemShortText(itemState)) { + return { + ...item, + displayText: item.shortText, + hidden: false, + }; + } + if (isItemTruncated(itemState)) { + const truncatedAvailableWidth = calculateAvailableTruncatedWidth( + items, + i, + itemsState, + availableWidth + ); + return { + ...item, + displayText: truncate(truncatedAvailableWidth, item.text), + hidden: false, + }; + } + return { + ...item, + displayText: item.text, + hidden: false, + }; + }); + +const TruncatingBreadcrumbs = React.memo( + // @ts-expect-error --- TODO revisit + ({ breadcrumbRenderer, currentBreadcrumbRenderer, items: originalItems }) => { + const [items, setItems] = useState([]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + const onResize = (entries: any) => { + /* + On resize callback from ResizeSensor, save the current width of the breadcrumbs. + */ + const availableWidth = Math.floor(entries[0].contentRect.width); + setItems(buildResizedItems(items, availableWidth)); + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + const renderBreadcrumb = (item: any, currentProp: any) => { + /* + Invoke the render callback to render the given breadcrumb. + */ + if (currentProp) { + return currentBreadcrumbRenderer(item); + } + return breadcrumbRenderer(item); + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + const renderBreadcrumbs = (bcItems: any) => + /* + Return list element/breadcrumb for each item. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + bcItems.map((item: any, i: number) => { + if (item.hidden) { + return null; + } + // TODO(cc) possibly "clean" each item back to the format expected by BP so we can spread the Breadcrumb-specific + // props in our render method, and so that knowledge of "displayText" vs "text" for example, is not required by + // parent components. + // See datasetSelector.renderBreadcrumb for our usage, and also the following for + // an example pattern: + // https://github.com/palantir/blueprint/blob/826cbdf95b577c43d5fe95b99c67ee2761c853e0/packages/core/src/components/breadcrumbs/breadcrumbs.tsx#L151 + // Could possibly also have an explicit breadcrumbsProps props to neatly encapsulate and spread + // breadcrumb-specific props, resulting in this component being a relatively transparent wrapper around + // BP's Breadcrumbs component. For an example pattern, see `overflowListProps` on BP Breadcrumbs component. + const currentItem = i === bcItems.length - 1; + return
  • {renderBreadcrumb(item, currentItem)}
  • ; + }); + + useEffect(() => { + /* + init/update truncating breadcrumb items + */ + setItems(initItems(originalItems)); + }, [originalItems]); + + return ( + +
      + {renderBreadcrumbs(items)} +
    +
    + ); + } +); + +export default TruncatingBreadcrumbs; diff --git a/client/src/components/framework/skeleton.tsx b/client/src/components/framework/skeleton.tsx new file mode 100644 index 00000000..2a6f4f0f --- /dev/null +++ b/client/src/components/framework/skeleton.tsx @@ -0,0 +1,49 @@ +// Core dependencies +import { SKELETON } from "@blueprintjs/core/lib/esnext/common/classes"; +import React from "react"; + +// App dependencies +import LeftSidebarSkeleton from "../leftSidebar/leftSidebarSkeleton"; +import Layout from "./layout"; +import RightSidebarSkeleton from "../rightSidebar/rightSidebarSkeleton"; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- FIXME: disabled temporarily on migrate to TS. +function Skeleton() { + /* + Skeleton layout component displayed when in loading state. + TODO(cc) + - Remove dupe of "graph" area inline styles + */ + return ( + + + {() => ( + <> +
    +
    +
    +
    + + )} + + + ); +} + +export default Skeleton; diff --git a/client/src/components/framework/title.tsx b/client/src/components/framework/title.tsx new file mode 100644 index 00000000..02e98261 --- /dev/null +++ b/client/src/components/framework/title.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import * as globals from "../../globals"; + +const Title = () => ( + + cell + + × + + gene + + ); + +export default Title; diff --git a/client/src/components/framework/toasters.css b/client/src/components/framework/toasters.css new file mode 100644 index 00000000..4249c3b3 --- /dev/null +++ b/client/src/components/framework/toasters.css @@ -0,0 +1,4 @@ +:local(.newTabToast) { + max-width: fit-content; + top: 42px; +} diff --git a/client/src/components/framework/toasters.ts b/client/src/components/framework/toasters.ts index bffbd885..5314a896 100644 --- a/client/src/components/framework/toasters.ts +++ b/client/src/components/framework/toasters.ts @@ -1,5 +1,9 @@ import { Position, Toaster, Intent } from "@blueprintjs/core"; +/* styles */ +// @ts-expect-error ts-migrate(2307) FIXME: Cannot find module './menubar.css' or its correspo... Remove this comment to see the full error message +import styles from "./toasters.css"; + /** Singleton toaster instance. Create separate instances for different options. */ const ToastTopCenter = Toaster.create({ @@ -55,3 +59,15 @@ export const postAsyncFailureToast = (message: any) => timeout: 10000, intent: Intent.WARNING, }); + +/* +Dataset opened in new tab + */ +export const postExplainNewTab = (message: string) => { + ToastTopCenter.show({ + className: styles.newTabToast, + message, + timeout: 5000, + intent: Intent.PRIMARY, + }); +}; diff --git a/client/src/components/graph/graph.tsx b/client/src/components/graph/graph.tsx index 654a46e0..a309ad4e 100644 --- a/client/src/components/graph/graph.tsx +++ b/client/src/components/graph/graph.tsx @@ -509,6 +509,10 @@ class Graph extends React.Component<{}, GraphState> { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -- - FIXME: disabled temporarily on migrate to TS. setReglCanvas = (canvas: any) => { + // Ignore null canvas on unmount + if (!canvas) { + return; + } this.reglCanvas = canvas; this.setState({ ...Graph.createReglState(canvas), @@ -558,7 +562,11 @@ class Graph extends React.Component<{}, GraphState> { handleEnd = this.handleLassoEnd.bind(this); handleCancel = this.handleLassoCancel.bind(this); } - const { svg: newToolSVG, tool, container } = setupSVGandBrushElements( + const { + svg: newToolSVG, + tool, + container, + } = setupSVGandBrushElements( selectionTool, handleStart, handleDrag, diff --git a/client/src/components/infoDrawer/infoDrawer.tsx b/client/src/components/infoDrawer/infoDrawer.tsx index 806f7225..1d6eb577 100644 --- a/client/src/components/infoDrawer/infoDrawer.tsx +++ b/client/src/components/infoDrawer/infoDrawer.tsx @@ -7,6 +7,8 @@ import { selectableCategoryNames } from "../../util/stateManager/controlsHelpers // @ts-expect-error ts-migrate(1238) FIXME: Unable to resolve signature of class decorator whe... Remove this comment to see the full error message @connect((state) => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. + collection: ((state as any).collections as any)?.collection, // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. schema: (state as any).annoMatrix.schema, // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. @@ -30,6 +32,8 @@ class InfoDrawer extends PureComponent { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- FIXME: disabled temporarily on migrate to TS. render() { const { + // @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Readon... Remove this comment to see the full error message + collection, // @ts-expect-error ts-migrate(2339) FIXME: Property 'position' does not exist on type 'Readon... Remove this comment to see the full error message position, // @ts-expect-error ts-migrate(2339) FIXME: Property 'aboutURL' does not exist on type 'Readon... Remove this comment to see the full error message @@ -58,15 +62,10 @@ class InfoDrawer extends PureComponent { }); return ( - + { - // eslint-disable-next-line no-constant-condition -- Temp removed contributor section to avoid publishing PII - if (!contributors || contributors.length === 0 || true) return null; - return ( - <> -

    Contributors

    -

    - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. */} - {contributors.map((contributor: any) => { - const { email, name, institution } = contributor; - - return ( - - {name} - {email && `(${email})`} - {affiliations.indexOf(institution) + 1} - - ); - })} -

    - {renderAffiliations(affiliations)} - - ); -}; - -// generates a list of unique institutions by order of appearance in contributors -const buildAffiliations = (contributors = []) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. - const affiliations: any = []; - contributors.forEach((contributor) => { - const { institution } = contributor; - if (affiliations.indexOf(institution) === -1) { - affiliations.push(institution); - } - }); - return affiliations; -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. -const renderAffiliations = (affiliations: any) => { - if (affiliations.length === 0) return null; - return ( - <> -

    Affiliations

    -
      - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. */} - {affiliations.map((item: any, index: any) => ( -
      - {index + 1} - {" "} - {item} -
      - ))} -
    - - ); -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. -const renderDOILink = (type: any, doi: any) => { - if (!doi) return null; - return ( - <> -

    {type}

    -

    - - {doi} - -

    - - ); -}; - const ONTOLOGY_KEY = "ontology_term_id"; -// Render list of metadata attributes found in categorical field -const renderDatasetMetadata = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. - singleValueCategories: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. - corporaMetadata: any -) => { - if (singleValueCategories.size === 0) return null; +const COLLECTION_LINK_ORDER_BY = [ + "DOI", + "DATA_SOURCE", + "RAW_DATA", + "PROTOCOL", + "LAB_WEBSITE", + "OTHER", +]; + +// @ts-expect-error --- TODO revisit +const buildCollectionLinks = (links) => { + /* + sort links by custom sort order, create view-friendly model of link types. + */ + const sortedLinks = [...links].sort(sortCollectionLinks); + return sortedLinks.map((link) => { + const { link_name: name, link_type: type, link_url: url } = link; + return { + name: buildLinkName(name, type, url), + type: transformLinkTypeToDisplay(type), + url, + }; + }); +}; + +// @ts-expect-error --- TODO revisit +const buildDatasetMetadata = (singleValueCategories, corporaMetadata) => { + /* + transform Corpora metadata and single value categories into sort and render-friendly format. + @returns [{key, value, tip}] + */ + const metadata = [ + ...transformCorporaMetadata(corporaMetadata), + ...transformSingleValueCategoriesMetadata(singleValueCategories), + ]; + metadata.sort(sortDatasetMetadata); + return metadata; +}; + +const getTableStyles = () => ({ tableLayout: "fixed", width: "100%" }); + +// @ts-expect-error --- TODO revisit +const sortCollectionLinks = (l0, l1) => + /* + sort collection links by custom order. + TODO(cc) revisit - improve readability here + */ + COLLECTION_LINK_ORDER_BY.indexOf(l1.type) - + COLLECTION_LINK_ORDER_BY.indexOf(l0.type); + +// @ts-expect-error --- TODO revisit +const buildLinkName = (name, type, url) => { + /* + determine name to display for collection link. + TODO(cc) error handling + */ + if (name) { + return name; + } + if (type === "DOI") { + return new URL(url).pathname.substring(1); + } + return new URL(url).host; +}; + +// @ts-expect-error --- TODO revisit +const sortDatasetMetadata = (m0, m1) => { + /* + sort metadata key value pairs by key - alpha, ascending + */ + if (m0.key < m1.key) { + return -1; + } + if (m0.key > m1.key) { + return 1; + } + return 0; +}; + +// @ts-expect-error --- TODO revisit +const transformCorporaMetadata = (corporaMetadata) => + /* + build array of view model objects from given Corpora metadata object. + @returns [{key, value}] + */ + Object.entries(corporaMetadata) + .filter(([, value]) => value) + .map(([key, value]) => ({ + key, + value, + })); + +// @ts-expect-error --- TODO revisit +const transformSingleValueCategoriesMetadata = (singleValueCategories) => + /* + build array of view model objects from given single value categories map, ignoring ontology terms or metadata + without values. add ontology terms as tooltips of their corresponding values. + @returns [{key, value, tip}] where tip is an optional ontology term for the category + */ + Array.from(singleValueCategories.entries()) + // @ts-expect-error --- TODO revisit + .filter(([key, value]) => { + if (key.indexOf(ONTOLOGY_KEY) >= 0) { + // skip ontology terms + return false; + } + // skip metadata without values + return value; + }) + // @ts-expect-error --- TODO revisit + .map(([key, value]) => { + const viewModel = { key, value: String(value) }; + // add ontology term as tool tip if specified + const tip = singleValueCategories.get(`${key}_${ONTOLOGY_KEY}`); + if (tip) { + // @ts-expect-error --- TODO revisit + viewModel.tip = tip; + } + return viewModel; + }); + +// @ts-expect-error --- TODO revisit +const transformLinkTypeToDisplay = (type) => { + /* + convert link type from upper snake case to title case + TODO(cc) revisit approach here, maybe create enum-type mapping to avoid string concat inside loop? + */ + const tokens = type.split("_"); + return ( + tokens + // @ts-expect-error --- TODO revisit + .map((token) => token.charAt(0) + token.slice(1).toLowerCase()) + .join(" ") + ); +}; + +// @ts-expect-error --- TODO revisit +const renderCollectionLinks = (collection) => { + /* + render collection contact and links. + TODO(cc) handle case where there is no contact and no links? + */ + const links = buildCollectionLinks(collection.links); + const { contact_name: contactName, contact_email: contactEmail } = collection; return ( <> -

    Dataset Metadata

    - - - - Field - Label - Ontology ID - - + {renderSectionTitle("Collection")} + {/* @ts-expect-error --- TODO revisit */} + - {Object.entries(corporaMetadata).map(([key, value]) => ( - - {`${key}:`} - {/* @ts-expect-error ts-migrate(2322) FIXME: Type 'unknown' is not assignable to type 'ReactNod... Remove this comment to see the full error message */} - {value} - + + Contact + {renderCollectionContactLink(contactName, contactEmail)} + + {links.map(({ name, type, url }, i) => ( + + {type} + + + {name} + + ))} - {Array.from(singleValueCategories).reduce((elems, pair) => { - // @ts-expect-error ts-migrate(2488) FIXME: Type 'unknown' must have a '[Symbol.iterator]()' m... Remove this comment to see the full error message - const [category, value] = pair; - // If the value is empty skip it - if (!value) return elems; - - // If this category is a ontology term, let's add its value to the previous node - if (String(category).includes(ONTOLOGY_KEY)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. - const prevElem = (elems as any).pop(); - const newChildren = [...prevElem.props.children]; - newChildren.splice(2, 1, [{value}]); - // Props aren't extensible so we must clone and alter the component to append the new child - // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. - (elems as any).push( - React.cloneElement(prevElem, prevElem.props, newChildren) - ); - } else { - // Create the list item - // eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. - (elems as any).push( - - {`${category}:`} - {value} - - - ); - } - return elems; - }, [])} ); }; -// Renders any links found in the config where link_type is not "SUMMARY" -// If there are no links in the config, render the aboutURL -// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. -const renderLinks = (projectLinks: any, aboutURL: any) => { - if (!projectLinks && !aboutURL) return null; - if (projectLinks) - return ( - <> -

    Project Links

    -
      - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS. */} - {projectLinks.map((link: any) => { - if (link.link_type === "SUMMARY") return null; - return ( -
    • - - {link.link_name} - -
    • - ); - })} -
    - - ); +// @ts-expect-error --- TODO revisit +const renderCollectionContactLink = (name, email) => { + /* + display collection contact's name with a link to their associated email. + */ + if (!name && !email) { + return null; + } + if (email) { + return {name}; + } + return name; +}; +// @ts-expect-error --- TODO revisit +const renderDatasetMetadata = (singleValueCategories, corporaMetadata) => { + /* + render dataset metadata, mix of meta from Corpora and attributes found in categorical field. + */ + if ( + singleValueCategories.size === 0 && + Object.entries(corporaMetadata).length === 0 + ) { + return null; + } + const metadata = buildDatasetMetadata(singleValueCategories, corporaMetadata); return ( <> -

    More Info

    -

    - - {aboutURL} - -

    + {renderSectionTitle("Dataset")} + + + {/* @ts-expect-error --- TODO revisit */} + {metadata.map(({ key, value, tip }) => ( + + {key} + + + {value} + + + + ))} + + ); }; +// @ts-expect-error --- TODO revisit +const renderSectionTitle = (title) => ( +

    + {title} +

    +); + const InfoFormat = React.memo( - // @ts-expect-error ts-migrate(2339) FIXME: Property 'datasetTitle' does not exist on type '{ ... Remove this comment to see the full error message - ({ datasetTitle, singleValueCategories, aboutURL, dataPortalProps = {} }) => { + // @ts-expect-error --- TODO revisit + ({ collection, singleValueCategories, dataPortalProps = {} }) => { if ( ["1.0.0", "1.1.0"].indexOf( dataPortalProps.version?.corpora_schema_version @@ -191,26 +246,15 @@ const InfoFormat = React.memo( ) { dataPortalProps = {}; } - const { - title, - publication_doi: doi, - preprint_doi: preprintDOI, - organism, - contributors, - project_links: projectLinks, - } = dataPortalProps; - - const affiliations = buildAffiliations(contributors); + const { organism } = dataPortalProps; return ( -
    +
    -

    {title ?? datasetTitle}

    - {renderContributors(contributors, affiliations)} +

    {collection.name}

    +

    {collection.description}

    + {renderCollectionLinks(collection)} {renderDatasetMetadata(singleValueCategories, { organism })} - {renderLinks(projectLinks, aboutURL)} - {renderDOILink("DOI", doi)} - {renderDOILink("Preprint DOI", preprintDOI)}
    ); diff --git a/client/src/components/leftSidebar/iconAbout.tsx b/client/src/components/leftSidebar/iconAbout.tsx new file mode 100644 index 00000000..7538d1a3 --- /dev/null +++ b/client/src/components/leftSidebar/iconAbout.tsx @@ -0,0 +1,28 @@ +/* core dependencies */ +import { Classes } from "@blueprintjs/core"; +import React from "react"; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- FIXME: disabled temporarily on migrate to TS. +function IconAbout() { + /* + TODO(cc) Generalize iconography into single component with icon prop. + */ + return ( + + + + ); +} +export default IconAbout; diff --git a/client/src/components/leftSidebar/iconDocument.tsx b/client/src/components/leftSidebar/iconDocument.tsx new file mode 100644 index 00000000..f7156e9e --- /dev/null +++ b/client/src/components/leftSidebar/iconDocument.tsx @@ -0,0 +1,28 @@ +/* core dependencies */ +import { Classes } from "@blueprintjs/core"; +import React from "react"; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- FIXME: disabled temporarily on migrate to TS. +function IconDocument() { + /* + TODO(cc) Generalize iconography into single component with icon prop. + */ + return ( + + + + ); +} +export default IconDocument; diff --git a/client/src/components/leftSidebar/iconGitHub.tsx b/client/src/components/leftSidebar/iconGitHub.tsx new file mode 100644 index 00000000..c99950e2 --- /dev/null +++ b/client/src/components/leftSidebar/iconGitHub.tsx @@ -0,0 +1,32 @@ +/* core dependencies */ +import { Classes } from "@blueprintjs/core"; +import React from "react"; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- FIXME: disabled temporarily on migrate to TS. +function IconGitHub() { + /* + TODO(cc) Generalize iconography into single component with icon prop. + */ + return ( + + + + + ); +} +export default IconGitHub; diff --git a/client/src/components/leftSidebar/iconSlack.tsx b/client/src/components/leftSidebar/iconSlack.tsx new file mode 100644 index 00000000..c86aa1d8 --- /dev/null +++ b/client/src/components/leftSidebar/iconSlack.tsx @@ -0,0 +1,54 @@ +/* core dependencies */ +import { Classes } from "@blueprintjs/core"; +import React from "react"; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- FIXME: disabled temporarily on migrate to TS. +function IconSlack() { + /* + TODO(cc) Generalize iconography into single component with icon prop. + */ + return ( + + + + + + + + + + + ); +} +export default IconSlack; diff --git a/client/src/components/leftSidebar/infoMenu.tsx b/client/src/components/leftSidebar/infoMenu.tsx index 9a5720f2..260a6edf 100644 --- a/client/src/components/leftSidebar/infoMenu.tsx +++ b/client/src/components/leftSidebar/infoMenu.tsx @@ -2,6 +2,12 @@ import React from "react"; import { Button, Menu, MenuItem, Popover, Position } from "@blueprintjs/core"; import { IconNames } from "@blueprintjs/icons"; +/* app dependencies */ +import IconAbout from "./iconAbout"; +import IconDocument from "./iconDocument"; +import IconGitHub from "./iconGitHub"; +import IconSlack from "./iconSlack"; + const InformationMenu = React.memo((props) => { // @ts-expect-error ts-migrate(2339) FIXME: Property 'libraryVersions' does not exist on type ... Remove this comment to see the full error message const { libraryVersions, tosURL, privacyURL } = props; @@ -11,60 +17,58 @@ const InformationMenu = React.memo((props) => { } rel="noopener" + target="_blank" + text="Documentation" /> } target="_blank" - icon="chat" text="Chat" rel="noopener" /> } target="_blank" - icon="git-branch" text="Github" rel="noopener" /> - - - {tosURL && ( - - )} - {privacyURL && ( - - )} + } + popoverProps={{ openOnTargetFocus: false }} + text="About cellxgene" + > + + + {tosURL && ( + + )} + {privacyURL && ( + + )} + } - position={Position.BOTTOM_RIGHT} + position={Position.BOTTOM_LEFT} modifiers={{ preventOverflow: { enabled: false }, hide: { enabled: false }, }} > -