Merge remote-tracking branch 'origin/main' into release-version-0.16.5

This commit is contained in:
maniarathi
2020-10-21 09:13:35 -07:00
53 changed files with 1682 additions and 620 deletions
+4 -2
View File
@@ -3,6 +3,8 @@ include ../common.mk
ANNOTATIONS := $(if $(ANNOTATIONS),$(ANNOTATIONS),../server/test/fixtures/pbmc3k-annotations.csv)
ANNOTATIONS_FILENAME := $(shell basename $(ANNOTATIONS))
CXG_CONFIG := $(if $(CXG_CONFIG), $(CXG_CONFIG), ./__tests__/e2e/test_config.yaml)
# Packaging
.PHONY: clean
clean:
@@ -31,9 +33,9 @@ start-frontend:
.PHONY: smoke-test
smoke-test:
start_server_and_test \
'CXG_OPTIONS="--disable-annotations" $(MAKE) start-server' \
'CXG_OPTIONS="--config-file $(CXG_CONFIG)" $(MAKE) start-server' \
$(CXG_SERVER_PORT) \
'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" npm run e2e -- --verbose false'
'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" CXG_AUTH_TYPE="test" npm run e2e -- --verbose false'
# start an instance of cellxgene and run the end-to-end annotations tests
.PHONY: smoke-test-annotations
+5 -6
View File
@@ -322,7 +322,7 @@ export async function login() {
await goToPage(appUrlBase);
await clickOn("auth-button");
await clickOn("log-in");
// (thuang): Auth0 form is unstable and unsafe for input until verified
await waitUntilFormFieldStable('[name="email"]');
@@ -341,16 +341,15 @@ export async function login() {
}
export async function logout() {
await clickOnUntil("menu", async () => {
await expect(page).toMatch("Log Out");
await clickOnUntil("user-info", async () => {
await waitByID("log-out");
await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle0" }),
expect(page).toClick("a", { text: "Log Out" }),
clickOn("log-out"),
]);
});
await expect(page).toMatch("Log In");
await waitByID("log-in");
}
async function waitUntilFormFieldStable(selector) {
+17
View File
@@ -17,6 +17,7 @@ import {
goToPage,
typeInto,
waitByID,
clickOnUntil,
} from "./puppeteerUtils";
import {
@@ -521,6 +522,22 @@ test("lasso moves after pan", async () => {
expect(panCount).toBe(initialCount);
});
const describeIfCalledByMakeFileTarget =
process.env.CXG_AUTH_TYPE?.toLowerCase() === "test"
? describe
: describe.skip;
describeIfCalledByMakeFileTarget("auth buttons", () => {
test("login then logout", async () => {
await goToPage(appUrlBase);
await clickOnUntil("log-in", async () => {
await page.waitForNavigation({ waitUntil: "networkidle0" });
await waitByID("user-info");
});
await logout();
});
});
const conditionalDescribe =
process.env.TEST_AUTH_INTEGRATION === "true" ? describe : describe.skip;
+47
View File
@@ -0,0 +1,47 @@
server:
app:
force_https: true
# By default, cellxgene will serve api requests from the same base url as the webpage.
# In general api_base_url and web_base_url will not need to be set.
# There are two reasons to set these parameters:
# 1. Oauth authentication is used; the oauth server will redirect back to the api_base_url after login,
# which then redirects back to the web_base_url. If the web_base_url is not set, it will default to
# the api_base_url. If oauth authentication is used, the api_base_url must be set.
# For a local test (where the server runs on "http://localhost:<port>"), then the api_base_url may be
# set to the string "local".
# 2. The cellxgene deploymnent is in an environment where the webpage and api have
# different base urls. In this case both api_base_url and web_base_url must be set.
# It is up to the server admin to ensure that the networking is setup correctly for this environment.
api_base_url: http://localhost:5005
web_base_url: http://localhost:3000
authentication:
# The authentication types may be "none", "session", "oauth"
# none: No authentication support, features like user_annotations must not be enabled.
# session: A session based userid is automatically generated. (no params needed)
# oauth: oauth2 is used for authentication; parameters are defined in params_oauth.
type: test
dataset:
app:
about_legal_tos: null
about_legal_privacy: null
presentation:
max_categories: 1000
custom_colors: true
user_annotations:
enable: false
type: local_file_csv
local_file_csv:
directory: null
file: null
ontology:
enable: false
obo_location: null
embeddings:
names: []
enable_reembedding: false
+22 -8
View File
@@ -1,5 +1,3 @@
const path = require("path");
const historyApiFallback = require("connect-history-api-fallback");
const chalk = require("chalk");
const express = require("express");
const favicon = require("serve-favicon");
@@ -11,35 +9,51 @@ const utils = require("./utils");
process.env.NODE_ENV = "development";
const CLIENT_PORT = process.env.CXG_CLIENT_PORT;
const { CXG_SERVER_PORT } = process.env;
const API = {
prefix: `http://localhost:${CXG_SERVER_PORT}/`,
};
// Set up compiler
const compiler = webpack(config);
compiler.plugin("invalid", () => {
compiler.hooks.invalid.tap("invalid", () => {
utils.clearConsole();
console.log("Compiling...");
});
compiler.plugin("done", (stats) => {
compiler.hooks.done.tap("done", (stats) => {
utils.formatStats(stats, CLIENT_PORT);
});
// Launch server
const app = express();
app.use(historyApiFallback({ verbose: false }));
app.use(
devMiddleware(compiler, {
logLevel: "warn",
publicPath: config.output.publicPath,
index: true,
})
);
app.use(favicon("./favicon.png"));
app.get("*", (req, res) => {
res.sendFile(path.resolve("index.html"));
app.get("/login", async (req, res) => {
try {
res.redirect(`${API.prefix}login?dataset=http://localhost:${CLIENT_PORT}`);
} catch (err) {
console.error(err);
}
});
app.get("/logout", async (req, res) => {
try {
res.redirect(`${API.prefix}logout?dataset=http://localhost:${CLIENT_PORT}`);
} catch (err) {
console.error(err);
}
});
app.listen(CLIENT_PORT, (err) => {
+4 -4
View File
@@ -43,12 +43,12 @@ async function configFetch(dispatch) {
async function userInfoFetch(dispatch) {
return fetchJson("userinfo").then((response) => {
const { userinfo } = response || {};
const { userinfo: userInfo } = response || {};
dispatch({
type: "userinfo load complete",
userinfo,
type: "userInfo load complete",
userInfo,
});
return userinfo;
return userInfo;
});
}
@@ -15,7 +15,7 @@ import {
idhash: state.config?.parameters?.["annotations-user-data-idhash"] ?? null,
annotations: state.annotations,
auth: state.config?.authentication,
userinfo: state.userinfo,
userInfo: state.userInfo,
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
}))
class FilenameDialog extends React.Component {
@@ -97,7 +97,7 @@ class FilenameDialog extends React.Component {
writableCategoriesEnabled,
annotations,
idhash,
userinfo,
userInfo,
} = this.props;
const { filenameText } = this.state;
@@ -105,7 +105,7 @@ class FilenameDialog extends React.Component {
annotations.promptForFilename &&
!annotations.dataCollectionNameIsReadOnly &&
!annotations.dataCollectionName &&
userinfo.is_authenticated ? (
userInfo.is_authenticated ? (
<Dialog
icon="tag"
title="Annotations Collection"
@@ -450,6 +450,7 @@ const Histogram = ({
isScatterplotYYaccessor: state.controls.scatterplotYYaccessor === field,
continuousSelectionRange: state.continuousSelection[myName],
isColorAccessor: state.colors.colorAccessor === field,
singleContinuousValues: state.singleContinuousValue.singleContinuousValues,
};
})
class HistogramBrush extends React.PureComponent {
@@ -608,18 +609,44 @@ class HistogramBrush extends React.PureComponent {
};
fetchAsyncProps = async () => {
const { annoMatrix } = this.props;
const { annoMatrix, field, dispatch, singleContinuousValues } = this.props;
const { isClipped } = annoMatrix;
if (singleContinuousValues.has(field)) {
return {
histogram: undefined,
range: undefined,
unclippedRange: undefined,
unclippedRangeColor: globals.blue,
isSingleValue: true,
OK2Render: false,
};
}
const query = this.createQuery();
const df = await annoMatrix.fetch(...query);
const column = df.icol(0);
// if we are clipped, fetch both our value and our unclipped value,
// as we need the absolute min/max range, not just the clipped min/max.
const summary = column.summarize();
const range = [summary.min, summary.max];
if (summary.min === summary.max && !isClipped) {
dispatch({
type: "add single continuous value",
field,
value: summary.min,
});
return {
histogram: undefined,
range,
unclippedRange: range,
unclippedRangeColor: globals.blue,
isSingleValue: true,
OK2Render: false,
};
}
const isSingleValue = summary.min === summary.max;
// if we are clipped, fetch both our value and our unclipped value,
// as we need the absolute min/max range, not just the clipped min/max.
let unclippedRange = [...range];
if (isClipped) {
const parent = await annoMatrix.viewOf.fetch(...query);
@@ -643,7 +670,6 @@ class HistogramBrush extends React.PureComponent {
this.height
);
const isSingleValue = summary.min === summary.max;
const nonFiniteExtent =
summary.min === undefined ||
summary.max === undefined ||
+4 -4
View File
@@ -15,7 +15,7 @@ import actions from "../../actions";
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
schema: state.annoMatrix?.schema,
ontology: state.ontology,
userinfo: state.userinfo,
userInfo: state.userInfo,
}))
class Categories extends React.Component {
constructor(props) {
@@ -132,7 +132,7 @@ class Categories extends React.Component {
writableCategoriesEnabled,
schema,
ontology,
userinfo,
userInfo,
} = this.props;
const ontologyEnabled = ontology?.enabled ?? false;
/* all names, sorted in display order. Will be rendered in this order */
@@ -213,7 +213,7 @@ class Categories extends React.Component {
{writableCategoriesEnabled ? (
<Tooltip
content={
userinfo.is_authenticated
userInfo.is_authenticated
? "Create a new category"
: "You must be logged in to create new categorical fields"
}
@@ -230,7 +230,7 @@ class Categories extends React.Component {
data-testid="open-annotation-dialog"
onClick={this.handleEnableAnnoMode}
intent="primary"
disabled={!userinfo.is_authenticated}
disabled={!userInfo.is_authenticated}
>
Create new category
</AnchorButton>
@@ -14,12 +14,7 @@ class Continuous extends React.PureComponent {
if (!schema) return null;
const obsIndex = schema.annotations.obs.index;
const allContinuousNames = schema.annotations.obs.columns
.filter(
(col) =>
col.type === "int32" ||
col.type === "float32" ||
col.type === "float64"
)
.filter((col) => col.type === "int32" || col.type === "float32")
.filter((col) => col.name !== obsIndex)
.filter((col) => !col.writable) // skip user annotations - they will be treated as categorical
.map((col) => col.name);
+25 -73
View File
@@ -1,13 +1,8 @@
import React, { PureComponent } from "react";
import { connect, shallowEqual } from "react-redux";
import { Drawer } from "@blueprintjs/core";
import Async from "react-async";
import InfoFormat from "./infoFormat";
import {
selectableCategoryNames,
createCategorySummaryFromDfCol,
} from "../../util/stateManager/controlsHelpers";
import { selectableCategoryNames } from "../../util/stateManager/controlsHelpers";
@connect((state) => {
return {
@@ -17,6 +12,7 @@ import {
aboutURL: state.config?.links?.["about-dataset"],
isOpen: state.controls.datasetDrawer,
dataPortalProps: state.config?.["corpora_props"] ?? {},
singleContinuousValues: state.singleContinuousValue.singleContinuousValues,
};
})
class InfoDrawer extends PureComponent {
@@ -24,40 +20,6 @@ class InfoDrawer extends PureComponent {
return !shallowEqual(props.watchProps, prevProps.watchProps);
}
fetchAsyncProps = async (props) => {
const { schema } = props.watchProps;
const { annoMatrix } = this.props;
const allCategoryNames = selectableCategoryNames(schema).sort();
const nonUserAnnoCategories = allCategoryNames.map((catName) => {
const isUserAnno = schema?.annotations?.obsByName[catName]?.writable;
if (!isUserAnno) return annoMatrix.fetch("obs", catName);
return null;
});
const singleValueCategories = (
await Promise.all(nonUserAnnoCategories)
).reduce((acc, categoryData, i) => {
// Actually check to see if it is null(user anno)
if (!categoryData) return acc;
const catName = allCategoryNames[i];
const column = categoryData.icol(0);
const colSchema = schema.annotations.obsByName[catName];
const categorySummary = createCategorySummaryFromDfCol(column, colSchema);
const { numCategoryValues } = categorySummary;
// Add to the array if the category has only one value
if (numCategoryValues === 1) {
acc.set(catName, categorySummary.allCategoryValues[0]);
}
return acc;
}, new Map());
return { singleValueCategories };
};
handleClose = () => {
const { dispatch } = this.props;
@@ -72,47 +34,37 @@ class InfoDrawer extends PureComponent {
schema,
isOpen,
dataPortalProps,
singleContinuousValues,
} = this.props;
const allCategoryNames = selectableCategoryNames(schema).sort();
const allSingleValues = new Map();
allCategoryNames.forEach((catName) => {
const isUserAnno = schema?.annotations?.obsByName[catName]?.writable;
const colSchema = schema.annotations.obsByName[catName];
if (!isUserAnno && colSchema.categories?.length === 1) {
allSingleValues.set(catName, colSchema.categories[0]);
}
});
singleContinuousValues.forEach((value, catName) => {
allSingleValues.set(catName, value);
});
return (
<Drawer
title="Dataset Overview"
onClose={this.handleClose}
{...{ isOpen, position }}
>
<Async
watchFn={InfoDrawer.watchAsync}
promiseFn={this.fetchAsyncProps}
watchProps={{ schema }}
>
<Async.Pending>
<InfoFormat
skeleton
{...{ datasetTitle, aboutURL, dataPortalProps }}
/>
</Async.Pending>
<Async.Rejected>
{(error) => {
console.error(error);
return <span>Failed to load info</span>;
}}
</Async.Rejected>
<Async.Fulfilled>
{(asyncProps) => {
const { singleValueCategories } = asyncProps;
return (
<InfoFormat
{...{
datasetTitle,
aboutURL,
singleValueCategories,
dataPortalProps,
}}
/>
);
}}
</Async.Fulfilled>
</Async>
<InfoFormat
{...{
datasetTitle,
aboutURL,
allSingleValues,
dataPortalProps,
}}
/>
</Drawer>
);
}
+33 -62
View File
@@ -1,15 +1,15 @@
import { H3, H1, UL, Classes } from "@blueprintjs/core";
import { H3, H1, UL } from "@blueprintjs/core";
import React from "react";
import Truncate from "../util/truncate";
const renderContributors = (contributors, affiliations, skeleton) => {
const renderContributors = (contributors, affiliations) => {
// eslint-disable-next-line no-constant-condition -- Temp removed contributor section to avoid publishing PII
if (!contributors || contributors.length === 0 || true) return null;
return (
<>
<H3 className={skeleton ? Classes.SKELETON : null}>Contributors</H3>
<p className={skeleton ? Classes.SKELETON : null}>
<H3>Contributors</H3>
<p>
{contributors.map((contributor) => {
const { email, name, institution } = contributor;
@@ -22,7 +22,7 @@ const renderContributors = (contributors, affiliations, skeleton) => {
);
})}
</p>
{renderAffiliations(affiliations, skeleton)}
{renderAffiliations(affiliations)}
</>
);
};
@@ -39,14 +39,14 @@ const buildAffiliations = (contributors = []) => {
return affiliations;
};
const renderAffiliations = (affiliations, skeleton) => {
const renderAffiliations = (affiliations) => {
if (affiliations.length === 0) return null;
return (
<>
<H3 className={skeleton ? Classes.SKELETON : null}>Affiliations</H3>
<H3>Affiliations</H3>
<UL>
{affiliations.map((item, index) => (
<div key={item} className={skeleton ? Classes.SKELETON : null}>
<div key={item}>
<sup>{index + 1}</sup>
{" "}
{item}
@@ -57,12 +57,12 @@ const renderAffiliations = (affiliations, skeleton) => {
);
};
const renderDOILink = (type, doi, skeleton) => {
const renderDOILink = (type, doi) => {
if (!doi) return null;
return (
<>
<H3 className={skeleton ? Classes.SKELETON : null}>{type}</H3>
<p className={skeleton ? Classes.SKELETON : null}>
<H3>{type}</H3>
<p>
<a href={doi} target="_blank" rel="noopener">
{doi}
</a>
@@ -71,12 +71,12 @@ const renderDOILink = (type, doi, skeleton) => {
);
};
const renderOrganism = (organism, skeleton) => {
const renderOrganism = (organism) => {
if (!organism) return null;
return (
<>
<H3 className={skeleton ? Classes.SKELETON : null}>Organism</H3>
<p className={skeleton ? Classes.SKELETON : null}>{organism}</p>
<H3>Organism</H3>
<p>{organism}</p>
</>
);
};
@@ -85,13 +85,13 @@ const ONTOLOGY_KEY = "ontology_term_id";
const CAT_WIDTH = "30%";
const VAL_WIDTH = "35%";
// Render list of metadata attributes found in categorical field
const renderSingleValueCategories = (singleValueCategories, skeleton) => {
if (singleValueCategories.size === 0) return null;
const renderSingleValues = (singleValues) => {
if (singleValues.size === 0) return null;
return (
<>
<H3 className={skeleton ? Classes.SKELETON : null}>Dataset Metadata</H3>
<H3>Dataset Metadata</H3>
<UL>
{Array.from(singleValueCategories).reduce((elems, pair) => {
{Array.from(singleValues).reduce((elems, pair) => {
const [category, value] = pair;
// If the value is empty skip it
if (!value) return elems;
@@ -115,11 +115,7 @@ const renderSingleValueCategories = (singleValueCategories, skeleton) => {
} else {
// Create the list item
elems.push(
<li
className={skeleton ? Classes.SKELETON : null}
key={category}
style={{ width: "100%" }}
>
<li key={category} style={{ width: "100%" }}>
<Truncate>
<span style={{ width: CAT_WIDTH }}>{`${category}:`}</span>
</Truncate>
@@ -138,20 +134,17 @@ const renderSingleValueCategories = (singleValueCategories, skeleton) => {
// Renders any links found in the config where link_type is not "SUMMARY"
// If there are no links in the config, render the aboutURL
const renderLinks = (projectLinks, aboutURL, skeleton) => {
const renderLinks = (projectLinks, aboutURL) => {
if (!projectLinks && !aboutURL) return null;
if (projectLinks)
return (
<>
<H3 className={skeleton ? Classes.SKELETON : null}>Project Links</H3>
<H3>Project Links</H3>
<UL>
{projectLinks.map((link) => {
if (link.link_type === "SUMMARY") return null;
return (
<li
key={link.link_name}
className={skeleton ? Classes.SKELETON : null}
>
<li key={link.link_name}>
<a href={link.link_url} target="_blank" rel="noopener">
{link.link_name}
</a>
@@ -164,14 +157,9 @@ const renderLinks = (projectLinks, aboutURL, skeleton) => {
return (
<>
<H3 className={skeleton ? Classes.SKELETON : null}>More Info</H3>
<H3>More Info</H3>
<p>
<a
className={skeleton ? Classes.SKELETON : null}
href={aboutURL}
target="_blank"
rel="noopener"
>
<a href={aboutURL} target="_blank" rel="noopener">
{aboutURL}
</a>
</p>
@@ -179,24 +167,9 @@ const renderLinks = (projectLinks, aboutURL, skeleton) => {
);
};
const NUM_CATEGORIES = 8;
// Generates arbitrary placeholder array for singleValueCategories skeleton shape
const singleValueCategoriesPlaceholder = Array.from(Array(NUM_CATEGORIES)).map(
(_, index) => {
return [index, index];
}
);
const InfoFormat = React.memo(
({
datasetTitle,
singleValueCategories = new Map(singleValueCategoriesPlaceholder),
aboutURL = "thisisabouthtelengthofaurl",
dataPortalProps = {},
skeleton = false,
}) => {
if (dataPortalProps.corpora_schema_version === "1.0.0") {
({ datasetTitle, allSingleValues, aboutURL, dataPortalProps = {} }) => {
if (dataPortalProps.version?.["corpora_schema_version"] !== "1.0.0") {
dataPortalProps = {};
}
const {
@@ -212,15 +185,13 @@ const InfoFormat = React.memo(
return (
<div style={{ margin: 24, overflow: "auto" }}>
<H1 className={skeleton ? Classes.SKELETON : null}>
{title ?? datasetTitle}
</H1>
{renderContributors(contributors, affiliations, skeleton)}
{renderDOILink("DOI", doi, skeleton)}
{renderDOILink("Preprint DOI", preprintDOI, skeleton)}
{renderOrganism(organism, skeleton)}
{renderSingleValueCategories(singleValueCategories, skeleton)}
{renderLinks(projectLinks, aboutURL, skeleton)}
<H1>{title ?? datasetTitle}</H1>
{renderContributors(contributors, affiliations)}
{renderDOILink("DOI", doi)}
{renderDOILink("Preprint DOI", preprintDOI)}
{renderOrganism(organism)}
{renderSingleValues(allSingleValues)}
{renderLinks(projectLinks, aboutURL)}
</div>
);
}
@@ -0,0 +1,72 @@
// jshint esversion: 6
import React from "react";
import { Button, Menu, MenuItem, Popover, Position } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
const InformationMenu = React.memo((props) => {
const { libraryVersions, tosURL, privacyURL } = props;
return (
<Popover
content={
<Menu>
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/"
target="_blank"
icon="book"
text="Documentation"
rel="noopener"
/>
<MenuItem
href="https://join-cellxgene-users.herokuapp.com/"
target="_blank"
icon="chat"
text="Chat"
rel="noopener"
/>
<MenuItem
href="https://github.com/chanzuckerberg/cellxgene"
target="_blank"
icon="git-branch"
text="Github"
rel="noopener"
/>
<MenuItem target="_blank" text={libraryVersions?.cellxgene || null} />
<MenuItem text="MIT License" />
{tosURL && (
<MenuItem
href={tosURL}
target="_blank"
text="Terms of Service"
rel="noopener"
/>
)}
{privacyURL && (
<MenuItem
href={privacyURL}
target="_blank"
text="Privacy Policy"
rel="noopener"
/>
)}
</Menu>
}
position={Position.BOTTOM_RIGHT}
modifiers={{
preventOverflow: { enabled: false },
hide: { enabled: false },
}}
>
<Button
data-testid="menu"
type="button"
icon={IconNames.INFO_SIGN}
style={{
cursor: "pointer",
verticalAlign: "middle",
}}
/>
</Popover>
);
});
export default InformationMenu;
@@ -7,15 +7,12 @@ import * as globals from "../../globals";
import Logo from "../framework/logo";
import Truncate from "../util/truncate";
import InfoDrawer from "../infoDrawer/infoDrawer";
import AuthButtons from "../menubar/authButtons";
import InformationMenu from "../menubar/infoMenu";
import InformationMenu from "./infoMenu";
const DATASET_TITLE_FONT_SIZE = 14;
@connect((state) => ({
datasetTitle: state.config?.displayNames?.dataset ?? "",
auth: state.config?.authentication,
userinfo: state.userinfo,
libraryVersions: state.config?.["library_versions"],
aboutLink: state.config?.links?.["about-dataset"],
tosURL: state.config?.parameters?.["about_legal_tos"],
@@ -30,8 +27,6 @@ class LeftSideBar extends React.Component {
render() {
const {
datasetTitle,
auth,
userinfo,
libraryVersions,
aboutLink,
privacyURL,
@@ -79,7 +74,7 @@ class LeftSideBar extends React.Component {
gene
</span>
</div>
<div style={{ marginRight: 5, position: "relative", top: -7 }}>
<div style={{ marginRight: 5, height: "100%" }}>
<Button
minimal
style={{
@@ -102,14 +97,9 @@ class LeftSideBar extends React.Component {
aboutLink,
tosURL,
privacyURL,
auth,
dispatch,
userinfo,
}}
/>
{!userinfo.is_authenticated ? (
<AuthButtons auth={auth} userinfo={userinfo} />
) : null}
</div>
</div>
);
+165 -21
View File
@@ -1,31 +1,175 @@
import React from "react";
import { AnchorButton, ButtonGroup, Tooltip } from "@blueprintjs/core";
import React, { useState } from "react";
import {
AnchorButton,
Button,
MenuItem,
Tooltip,
Popover,
Menu,
Elevation,
PopoverPosition,
Checkbox,
Card,
} from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import * as globals from "../../globals";
import styles from "./menubar.css";
import { storageGet, storageSet, KEYS } from "../util/localStorage";
const BASE_EMOJI = [0x1f9d1, 0x1f468, 0x1f469];
const SKIN_TONES = [0x1f3fb, 0x1f3fc, 0x1f3fd, 0x1f3fe, 0x1f3ff];
const MICROSCOPE = 0x1f52c;
const ZERO_WIDTH_JOINER = 0x0200d;
const LOGIN_PROMPT_OFF = "off";
const Auth = React.memo((props) => {
const { auth, userinfo } = props;
const [isPromptOpen, setIsPromptOpen] = useState(shouldShowPrompt());
if (!auth || (auth && !auth.requires_client_login)) return null;
const { auth, userInfo } = props;
return (
<ButtonGroup className={styles.menubarButton}>
<Tooltip
content="Log in to cellxgene"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
data-testid="auth-button"
disabled={false}
href={!userinfo.is_authenticated ? auth.login : auth.logout}
>
{!userinfo.is_authenticated ? "Log In" : "Log Out"}
</AnchorButton>
</Tooltip>
</ButtonGroup>
const isAuthenticated = userInfo && userInfo.is_authenticated;
window.userInfo = userInfo;
const randomInt = Math.random() * 15;
const sexIndex = Math.floor(randomInt / 5);
const skinToneIndex = Math.floor(randomInt % 5);
const scientist = String.fromCodePoint(
BASE_EMOJI[sexIndex],
SKIN_TONES[skinToneIndex],
ZERO_WIDTH_JOINER,
MICROSCOPE
);
if (!shouldShowAuth()) return null;
if (isAuthenticated) {
const PopoverContent = (
<Menu>
<MenuItem
data-testid="user-email"
text={`Logged in as: ${userInfo.email}`}
/>
<MenuItem
data-testid="log-out"
text="Log Out"
href={auth.logout}
icon={IconNames.LOG_OUT}
/>
</Menu>
);
return (
<Popover content={PopoverContent}>
<Button
data-testid="user-info"
className={styles.menubarButton}
style={{ padding: 0 }}
>
{/* eslint-disable-next-line no-constant-condition -- disable profile picture until CSP is tweaked */}
{userInfo?.picture && false ? (
<img alt="profile" size="21px" src={userInfo?.picture} />
) : (
<span style={{ fontSize: "18px" }}>{scientist}</span>
)}
</Button>
</Popover>
);
}
const LoginButton = (
<Tooltip
content="Log in to cellxgene"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
data-testid="log-in"
href={auth.login}
className={styles.menubarButton}
>
Log In
</AnchorButton>
</Tooltip>
);
if (isPromptOpen) {
return (
<Popover
position={PopoverPosition.AUTO_END}
isOpen
content={<PromptContent setIsPromptOpen={setIsPromptOpen} />}
onInteraction={setIsPromptOpen}
>
{LoginButton}
</Popover>
);
}
return LoginButton;
function shouldShowAuth() {
return auth && auth.requires_client_login;
}
function shouldShowPrompt() {
if (storageGet(KEYS.LOGIN_PROMPT) === LOGIN_PROMPT_OFF) return false;
return shouldShowAuth && !isAuthenticated;
}
});
function PromptContent({ setIsPromptOpen }) {
const [isChecked, setIsChecked] = useState(false);
function handleOKClick() {
if (isChecked) {
storageSet(KEYS.LOGIN_PROMPT, LOGIN_PROMPT_OFF);
}
setIsPromptOpen(false);
}
function handleCheckboxChange() {
setIsChecked(!isChecked);
}
return (
<Card style={{ width: "500px" }} elevation={Elevation.TWO}>
<p>
Logging in will enable you to create your own categories and labels.
Logging in later will reset cellxgene to the default view and cause you
to lose progress.
</p>
<Checkbox
style={{ width: "230px" }}
checked={isChecked}
onChange={handleCheckboxChange}
data-testid="login-hint-do-not-show-again"
>
Do not show me this message again
</Checkbox>
<div
style={{ display: "flex", justifyContent: "flex-end", marginTop: 15 }}
>
<Button
onClick={handleOKClick}
intent="primary"
data-testid="login-hint-yes"
>
Acknowledge
</Button>
</div>
</Card>
);
}
export default Auth;
+5 -1
View File
@@ -7,6 +7,7 @@ import styles from "./menubar.css";
import actions from "../../actions";
import Clip from "./clip";
import AuthButtons from "./authButtons";
import Subset from "./subset";
import UndoRedoReset from "./undoRedo";
import DiffexpButtons from "./diffexpButtons";
@@ -41,7 +42,7 @@ import { getEmbSubsetView } from "../../util/stateManager/viewStackHelpers";
celllist2: state.differential.celllist2,
libraryVersions: state.config?.["library_versions"],
auth: state.config?.authentication,
userinfo: state.userinfo,
userInfo: state.userInfo,
undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0,
aboutLink: state.config?.links?.["about-dataset"],
@@ -216,6 +217,8 @@ class MenuBar extends React.PureComponent {
subsetPossible,
subsetResetPossible,
enableReembedding,
userInfo,
auth,
} = this.props;
const { pendingClipPercentiles } = this.state;
@@ -241,6 +244,7 @@ class MenuBar extends React.PureComponent {
zIndex: 3,
}}
>
<AuthButtons {...{ auth, userInfo }} />
<UndoRedoReset
dispatch={dispatch}
undoDisabled={undoDisabled}
-108
View File
@@ -1,108 +0,0 @@
// jshint esversion: 6
import React from "react";
import {
Button,
ButtonGroup,
Classes,
Menu,
MenuItem,
Popover,
Position,
} from "@blueprintjs/core";
import styles from "./menubar.css";
const handleClick = (dispatch) => {
dispatch({ type: "toggle dataset drawer" });
};
const InformationMenu = React.memo((props) => {
const {
libraryVersions,
tosURL,
privacyURL,
auth,
userinfo,
dispatch,
} = props;
return (
<ButtonGroup className={`${styles.menubarButton}`}>
<Popover
content={
<Menu>
<MenuItem
onClick={() => handleClick(dispatch)}
icon="info-sign"
text="Dataset Overview"
/>
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/"
target="_blank"
icon="book"
text="Documentation"
rel="noopener"
/>
<MenuItem
href="https://join-cellxgene-users.herokuapp.com/"
target="_blank"
icon="chat"
text="Chat"
rel="noopener"
/>
<MenuItem
href="https://github.com/chanzuckerberg/cellxgene"
target="_blank"
icon="git-branch"
text="Github"
rel="noopener"
/>
<MenuItem
target="_blank"
text={
libraryVersions && libraryVersions.cellxgene
? libraryVersions.cellxgene
: null
}
/>
<MenuItem text="MIT License" />
{tosURL ? (
<MenuItem href={tosURL} target="_blank" text="Terms of Service" />
) : null}
{privacyURL ? (
<MenuItem
href={privacyURL}
target="_blank"
text="Privacy Policy"
rel="noopener"
/>
) : null}
{auth?.["requires_client_login"] &&
userinfo?.["is_authenticated"] ? (
<>
<MenuItem text={`Logged in as: ${userinfo.email}`} />
<MenuItem text="Log Out" href={auth.logout} />
</>
) : null}
</Menu>
}
position={Position.BOTTOM_RIGHT}
modifiers={{
preventOverflow: { enabled: false },
hide: { enabled: false },
}}
>
<Button
data-testid="menu"
type="button"
className={`${Classes.BUTTON} bp3-icon-info-sign`}
style={{
cursor: "pointer",
}}
/>
</Popover>
</ButtonGroup>
);
});
export default InformationMenu;
+4 -23
View File
@@ -8,26 +8,7 @@ import {
Colors,
Icon,
} from "@blueprintjs/core";
const CookieDecision = "cxg.cookieDecision";
function storageGet(key, defaultValue = null) {
try {
const val = window.localStorage.getItem(key);
if (val === null) return defaultValue;
return val;
} catch (e) {
return defaultValue;
}
}
function storageSet(key, value) {
try {
window.localStorage.setItem(key, value);
} catch {
// continue
}
}
import { storageGet, storageSet, KEYS } from "../util/localStorage";
@connect((state) => ({
tosURL: state.config?.parameters?.["about_legal_tos"],
@@ -37,7 +18,7 @@ class TermsPrompt extends React.PureComponent {
constructor(props) {
super(props);
const { tosURL, privacyURL } = this.props;
const cookieDecision = storageGet(CookieDecision, null);
const cookieDecision = storageGet(KEYS.COOKIE_DECISION, null);
const hasDecided = cookieDecision !== null;
this.state = {
hasDecided,
@@ -55,7 +36,7 @@ class TermsPrompt extends React.PureComponent {
handleOK = () => {
this.setState({ isOpen: false });
storageSet(CookieDecision, "yes");
storageSet(KEYS.COOKIE_DECISION, "yes");
if (window.cookieDecisionCallback instanceof Function) {
try {
window.cookieDecisionCallback();
@@ -67,7 +48,7 @@ class TermsPrompt extends React.PureComponent {
handleNo = () => {
this.setState({ isOpen: false });
storageSet(CookieDecision, "no");
storageSet(KEYS.COOKIE_DECISION, "no");
};
renderTos() {
@@ -0,0 +1,22 @@
export const KEYS = {
COOKIE_DECISION: "cxg.cookieDecision",
LOGIN_PROMPT: "cxg.LOGIN_PROMPT",
};
export function storageGet(key, defaultValue = null) {
try {
const val = window.localStorage.getItem(key);
if (val === null) return defaultValue;
return val;
} catch (e) {
return defaultValue;
}
}
export function storageSet(key, value) {
try {
window.localStorage.setItem(key, value);
} catch {
// continue
}
}
+4 -3
View File
@@ -4,7 +4,7 @@ import thunk from "redux-thunk";
import cascadeReducers from "./cascade";
import undoable from "./undoable";
import config from "./config";
import userinfo from "./userinfo";
import userInfo from "./userInfo";
import annoMatrix from "./annoMatrix";
import obsCrossfilter from "./obsCrossfilter";
import categoricalSelection from "./categoricalSelection";
@@ -21,7 +21,7 @@ import centroidLabels from "./centroidLabels";
import pointDialation from "./pointDilation";
import { reembedController } from "./reembed";
import { gcMiddleware as annoMatrixGC } from "../annoMatrix";
import singleContinuousValue from "./singleContinuousValue";
import undoableConfig from "./undoableConfig";
const Reducer = undoable(
@@ -32,6 +32,7 @@ const Reducer = undoable(
["ontology", ontology],
["annotations", annotations],
["layoutChoice", layoutChoice],
["singleContinuousValue", singleContinuousValue],
["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection],
["graphSelection", graphSelection],
@@ -42,7 +43,7 @@ const Reducer = undoable(
["pointDilation", pointDialation],
["reembedController", reembedController],
["autosave", autosave],
["userinfo", userinfo],
["userInfo", userInfo],
]),
[
"annoMatrix",
@@ -0,0 +1,14 @@
const initialState = {
singleContinuousValues: new Map(),
};
const singleContinuousValue = (state = initialState, action) => {
switch (action.type) {
case "add single continuous value":
state.singleContinuousValues.set(action.field, action.value);
return state;
default:
return state;
}
};
export default singleContinuousValue;
@@ -1,4 +1,3 @@
// jshint esversion: 6
const UserInfo = (state = {}, action) => {
switch (action.type) {
case "initial data load start":
@@ -7,12 +6,12 @@ const UserInfo = (state = {}, action) => {
loading: true,
error: null,
};
case "userinfo load complete":
case "userInfo load complete":
return {
...state,
loading: false,
error: null,
...action.userinfo,
...action.userInfo,
};
case "initial data load error":
return {
+7 -3
View File
@@ -106,15 +106,19 @@ export function loadUserColorConfig(userColors) {
return -1;
})
.reduce(
(acc, label, i) => {
(acc, label) => {
const color = parseRGB(userColors[category][label]);
acc[0][label] = color;
acc[1][i] = d3.rgb(255 * color[0], 255 * color[1], 255 * color[2]);
acc[1][label] = d3.rgb(
255 * color[0],
255 * color[1],
255 * color[2]
);
return acc;
},
[{}, {}]
);
const scale = (i) => scaleMap[i];
const scale = (label) => scaleMap[label];
convertedUserColors[category] = { colors, scale };
});
return convertedUserColors;