mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-28 11:38:11 +08:00
Merge remote-tracking branch 'origin/main' into release-version-0.16.5
This commit is contained in:
+4
-2
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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 {
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user