From e6e358ddc81d3c5f42e9fc98a9ade4a1db62ad9b Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Wed, 24 Mar 2021 16:33:26 -0400 Subject: [PATCH] Gene sets UI, right sidebar refactor (#2097) * prototyping * render histos on open gene set * prototyping * render histos on open gene set * factor out add genes to own component * remove unused import * mock reducer * color by geneset stub * menus and buttons * geneset dialogue stub * remove heatmap mock * componetize histogram * reenable add genes * re-add isuserdefined * test data * remove have fetched * add isExpanded state to gene, and pass to histogram * expand button * toggleable * mini * bump number of genes to 50 * don't clear diffexp on subset * move create category to top * render diffexp as geneset * geneset show mean expression * gene set reducer * add geneset UI reducer * wire e2e gene set loading prototype * fix sniffing bug * fix typo * add gene modals * client/src/actions/ * add autosave * rename data-dir cli param * add geneset, add gene, delete set * prototype: remove csv upload placeholder * handle delete gene from set * prepopulate geneset with genes from modal * add geneset: rename action * icons, language consistency * chevron after * handle empty string case on genes for create geneset * edit geneset * fix language on create * copy correction * add popper2 upgrade react popper upgrade react popper adding popover2 package * truncate uses tooltip2 * gene set button text typo * remove logging * moving server over * remove test imports * don't try to destructure map, use array.from * fix add gene map datastructure error * Revert "fix add gene map datastructure error" This reverts commit b0eed459529d9c39a7849ce157a3dc0ed2b2b8b9. * name --> genesetName, genes --> geneSymbols * add gene to geneset, temporary format * handle empty case, clear form input * lint -- genesets wasn't passed via props * userinfo * move genes string to object conversion to action * remove tmp gene description * emptystring default for description * remove empty string * remove top level package json * remove package lock as well * remove flag for feature toggle * remove comments in geneset * comment cleanup * remove comment * revert diffexp genes to 10 * color by gene set * disable color by gene set * Gene menus are now inline, remove dead prototype code * remove todo, magic number to variable * remove jshint in rightsidebar Co-authored-by: Severiano Badajoz * remove unused geneset validation code * tmp format pending geneset description * move magic number into variable * reorganize genesetsUI reducer pending tests * rewire edit given new action name * add basic validation and feedback for geneset name uniqueness * mv annoDialog * mv label, repair paths * Update client/src/components/brushableHistogram/header.js Co-authored-by: Severiano Badajoz * add imports for icon in histo * update jest snapshots given blueprint/tooltip2 usage of index -1 * ensure no empty paragraph * intent from blueprint * remove remainder of jshint references * do not push undo when autosave fires * fix autosave bugs * remove todos * clamp to util * scient to util * revert clearing diffexp * rename value to be more specific stacked bar * clean up logging and commetns * remove gene entry tests pending rewrite * tab index -1 * update jest snapshot, blueprint tooltip 2 * caret margin * snapshot update * ensure histogram is centered * add geneset actions to config * comment maybeScientific * comment clamp * comment ui reducer * remove prototype code * remove error log * remove references to bl.ocks * componetize parseBulkGeneString * catch case where geneset rename same name * genesetui reducer tests * add geneset ui to index reducer config Co-authored-by: bkmartinjr Co-authored-by: Severiano Badajoz --- .../e2e/__snapshots__/e2e.test.js.snap | 2 +- .../__snapshots__/e2eAnnotations.test.js.snap | 16 +- client/__tests__/e2e/e2e.test.js | 170 ------ client/__tests__/reducers/genesetsUI.test.js | 75 +++ .../util/typedCrossfilter/bitArray.test.js | 2 - .../typedCrossfilter/positiveInterval.test.js | 2 - client/package-lock.json | 255 +++++--- client/package.json | 3 + client/src/actions/annotation.js | 16 +- .../{categorical => }/annoDialog.js | 8 + client/src/components/app.js | 1 - .../src/components/autosave/filenameDialog.js | 6 +- client/src/components/autosave/index.js | 2 +- .../components/brushableHistogram/error.js | 17 + .../components/brushableHistogram/footer.js | 90 +++ .../components/brushableHistogram/header.js | 100 +++ .../brushableHistogram/histogram.js | 185 ++++++ .../components/brushableHistogram/index.js | 570 +++--------------- .../components/brushableHistogram/loading.js | 42 ++ .../category/annoDialogAddLabel.js | 4 +- .../category/annoDialogEditCategoryName.js | 4 +- .../categorical/category/annoMenuCategory.js | 6 +- client/src/components/categorical/index.js | 61 +- .../src/components/categorical/value/index.js | 30 +- .../components/categorical/value/occupancy.js | 1 - .../continuous/setupParallelCoordinates.js | 1 - client/src/components/continuous/util.js | 1 - .../src/components/continuousLegend/index.js | 2 - client/src/components/framework/container.js | 1 - client/src/components/framework/layout.js | 1 - client/src/components/geneExpression/gene.js | 188 ++++++ .../src/components/geneExpression/geneSet.js | 183 ++++++ client/src/components/geneExpression/index.js | 105 ++-- .../menus/addGeneToGenesetDialogue.js | 92 +++ .../geneExpression/{ => menus}/addGenes.js | 14 +- .../menus/createGenesetDialogue.js | 149 +++++ .../menus/editGenesetNameDialogue.js | 95 +++ .../geneExpression/menus/genesetMenus.js | 120 ++++ client/src/components/graph/graph.js | 1 - client/src/components/graph/setupLasso.js | 2 - .../src/components/graph/setupSVGandBrush.js | 1 - .../{categorical => }/labelInput.js | 0 client/src/components/leftSidebar/index.js | 3 +- client/src/components/leftSidebar/infoMenu.js | 1 - .../src/components/menubar/cellSetButtons.js | 1 - client/src/components/rightSidebar/index.js | 4 +- client/src/components/scatterplot/util.js | 1 - client/src/components/util/truncate.js | 12 +- client/src/index.css | 1 + client/src/reducers/autosave.js | 2 +- client/src/reducers/config.js | 1 - client/src/reducers/controls.js | 2 - client/src/reducers/differential.js | 2 - client/src/reducers/genesets.js | 1 + client/src/reducers/genesetsUI.js | 88 +++ client/src/reducers/index.js | 3 + client/src/reducers/undoableConfig.js | 21 + client/src/util/clamp.js | 13 + client/src/util/maybeScientific.js | 27 + client/src/util/parseBulkGeneString.js | 10 + client/src/util/parseRGB.js | 1 - client/src/util/scaleLinear.js | 2 - client/src/util/scaleRGB.js | 1 - .../typedCrossfilter/positiveIntervals.js | 2 - client/src/util/typedCrossfilter/util.js | 2 - package.json | 6 - 66 files changed, 1926 insertions(+), 905 deletions(-) create mode 100644 client/__tests__/reducers/genesetsUI.test.js rename client/src/components/{categorical => }/annoDialog.js (89%) create mode 100644 client/src/components/brushableHistogram/error.js create mode 100644 client/src/components/brushableHistogram/footer.js create mode 100644 client/src/components/brushableHistogram/header.js create mode 100644 client/src/components/brushableHistogram/histogram.js create mode 100644 client/src/components/brushableHistogram/loading.js create mode 100644 client/src/components/geneExpression/gene.js create mode 100644 client/src/components/geneExpression/geneSet.js create mode 100644 client/src/components/geneExpression/menus/addGeneToGenesetDialogue.js rename client/src/components/geneExpression/{ => menus}/addGenes.js (96%) create mode 100644 client/src/components/geneExpression/menus/createGenesetDialogue.js create mode 100644 client/src/components/geneExpression/menus/editGenesetNameDialogue.js create mode 100644 client/src/components/geneExpression/menus/genesetMenus.js rename client/src/components/{categorical => }/labelInput.js (100%) create mode 100644 client/src/reducers/genesetsUI.js create mode 100644 client/src/util/clamp.js create mode 100644 client/src/util/maybeScientific.js create mode 100644 client/src/util/parseBulkGeneString.js delete mode 100644 package.json diff --git a/client/__tests__/e2e/__snapshots__/e2e.test.js.snap b/client/__tests__/e2e/__snapshots__/e2e.test.js.snap index d33b3262..d5c42b1b 100644 --- a/client/__tests__/e2e/__snapshots__/e2e.test.js.snap +++ b/client/__tests__/e2e/__snapshots__/e2e.test.js.snap @@ -2,4 +2,4 @@ exports[`did launch page launched 1`] = `"pbmc3kc3k"`; -exports[`metadata loads categories and values from dataset appear 1`] = `"
louvainvain
"`; +exports[`metadata loads categories and values from dataset appear 1`] = `"
louvainvain
"`; diff --git a/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap b/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap index 56a349b2..a82cc979 100644 --- a/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap +++ b/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap @@ -2,22 +2,22 @@ exports[`annotations stacked bar graph renders 1`] = ` Array [ - "
TEST-LABELLABEL
0
", - "
unassignedigned
2133
", + "
TEST-LABELLABEL
0
", + "
unassignedigned
2133
", ] `; exports[`annotations stacked bar graph renders 2`] = ` Array [ - "
TEST-LABELLABEL
0
", - "
unassignedigned
2638
", + "
TEST-LABELLABEL
0
", + "
unassignedigned
2638
", ] `; -exports[`annotations truncate midpoint whitespace 1`] = `"123 456 456"`; +exports[`annotations truncate midpoint whitespace 1`] = `"123 456 456"`; -exports[`annotations truncate midpoint whitespace 2`] = `"123 456 456"`; +exports[`annotations truncate midpoint whitespace 2`] = `"123 456 456"`; -exports[`annotations truncate single character 1`] = `"T"`; +exports[`annotations truncate single character 1`] = `"T"`; -exports[`annotations truncate single character 2`] = `"T"`; +exports[`annotations truncate single character 2`] = `"T"`; diff --git a/client/__tests__/e2e/e2e.test.js b/client/__tests__/e2e/e2e.test.js index 427db7db..c0f7eb36 100644 --- a/client/__tests__/e2e/e2e.test.js +++ b/client/__tests__/e2e/e2e.test.js @@ -15,23 +15,17 @@ import { getOneElementInnerHTML, getTestId, goToPage, - typeInto, waitByID, clickOnUntil, } from "./puppeteerUtils"; import { - addGeneToSearch, - bulkAddGenes, calcDragCoordinates, clip, drag, getAllCategoriesAndCounts, - getAllHistograms, getCellSetCount, - runDiffExp, selectCategory, - subset, login, logout, } from "./cellxgeneActions"; @@ -149,47 +143,6 @@ describe("cell selection", () => { }); }); -describe("gene entry", () => { - test("search for single gene", async () => { - await goToPage(appUrlBase); - await addGeneToSearch(data.genes.search); - }); - - test("bulk add genes", async () => { - await goToPage(appUrlBase); - - const testGenes = data.genes.bulkadd; - - await bulkAddGenes(testGenes); - const allHistograms = await getAllHistograms( - "histogram-user-gene", - testGenes - ); - - expect(allHistograms).toEqual(expect.arrayContaining(testGenes)); - expect(allHistograms).toHaveLength(testGenes.length); - }); -}); - -describe("differential expression", () => { - test("selects cells, saves them and performs diffexp", async () => { - await goToPage(appUrlBase); - - await runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2); - - const allHistograms = await getAllHistograms( - "histogram-diffexp", - data.diffexp["gene-results"] - ); - - expect(allHistograms).toEqual( - expect.arrayContaining(data.diffexp["gene-results"]) - ); - - expect(allHistograms).toHaveLength(data.diffexp["gene-results"].length); - }); -}); - describe("subset", () => { test("subset - cell count matches", async () => { await goToPage(appUrlBase); @@ -236,94 +189,6 @@ describe("subset", () => { const cellCount = await getCellSetCount(1); expect(cellCount).toBe(data.subset.lasso.count); }); - - test("undo selection appends the top diff exp genes to user defined genes", async () => { - await goToPage(appUrlBase); - - const userDefinedGenes = data.genes.bulkadd; - const diffExpGenes = data.diffexp["gene-results"]; - - await bulkAddGenes(userDefinedGenes); - const userDefinedHistograms = await getAllHistograms( - "histogram-user-gene", - userDefinedGenes - ); - - expect(userDefinedHistograms).toEqual( - expect.arrayContaining(userDefinedGenes) - ); - - await subset({ x1: 0.15, y1: 0.1, x2: 0.98, y2: 0.98 }); - await runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2); - - const diffExpHistograms = await getAllHistograms( - "histogram-diffexp", - diffExpGenes - ); - - expect(diffExpHistograms).toEqual(expect.arrayContaining(diffExpGenes)); - - await clickOn("reset-subset-button"); - const expected = [].concat(userDefinedGenes, diffExpGenes); - const userDefinedHistogramsAfterSubset = await getAllHistograms( - "histogram-user-gene", - expected - ); - - expect(userDefinedHistogramsAfterSubset).toEqual( - expect.arrayContaining(expected) - ); - }); - - test("subset selection appends the top diff exp genes to user defined genes", async () => { - await goToPage(appUrlBase); - - const userDefinedGenes = data.genes.bulkadd; - const diffExpGenes = data.diffexp["gene-results"]; - - await bulkAddGenes(userDefinedGenes); - const userDefinedHistograms = await getAllHistograms( - "histogram-user-gene", - userDefinedGenes - ); - - expect(userDefinedHistograms).toEqual( - expect.arrayContaining(userDefinedGenes) - ); - - await subset({ x1: 0.15, y1: 0.1, x2: 0.98, y2: 0.98 }); - await runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2); - - const diffExpHistograms = await getAllHistograms( - "histogram-diffexp", - diffExpGenes - ); - - expect(diffExpHistograms).toEqual(expect.arrayContaining(diffExpGenes)); - - await subset({ x1: 0.16, y1: 0.11, x2: 0.97, y2: 0.97 }); - - const expected = [].concat(userDefinedGenes, diffExpGenes); - const userDefinedHistogramsAfterSubset = await getAllHistograms( - "histogram-user-gene", - expected - ); - - expect(userDefinedHistogramsAfterSubset).toEqual( - expect.arrayContaining(expected) - ); - }); -}); - -describe("scatter plot", () => { - test("scatter plot appears", async () => { - await goToPage(appUrlBase); - - await bulkAddGenes(Object.values(data.scatter.genes)); - await clickOn(`plot-x-${data.scatter.genes.x}`); - await clickOn(`plot-y-${data.scatter.genes.y}`); - await waitByID("scatterplot"); - }); }); describe("clipping", () => { @@ -340,30 +205,6 @@ describe("clipping", () => { const cellCount = await getCellSetCount(1); expect(cellCount).toBe(data.clip.count); }); - - test("clip gene", async () => { - await goToPage(appUrlBase); - - await typeInto("gene-search", data.clip.gene); - await page.keyboard.press("Enter"); - - await page.waitForSelector(`[data-testid='histogram-${data.clip.gene}']`); - - await clip(data.clip.min, data.clip.max); - - const histBrushableAreaId = `histogram-${data.clip.gene}-plot-brushable-area`; - - const coords = await calcDragCoordinates( - histBrushableAreaId, - data.clip["coordinates-as-percent"] - ); - - await drag(histBrushableAreaId, coords.start, coords.end); - - const cellCount = await getCellSetCount(1); - - expect(cellCount).toBe(data.clip["gene-cell-count"]); - }); }); // interact with UI elements just that they do not break @@ -381,17 +222,6 @@ describe("ui elements don't error", () => { } }); - test("color by for gene", async () => { - await goToPage(appUrlBase); - - await typeInto("gene-search", data.genes.search); - await page.keyboard.press("Enter"); - await page.waitForSelector( - `[data-testid='histogram-${data.genes.search}']` - ); - await clickOn(`colorby-${data.genes.search}`); - }); - test("pan and zoom", async () => { await goToPage(appUrlBase); diff --git a/client/__tests__/reducers/genesetsUI.test.js b/client/__tests__/reducers/genesetsUI.test.js new file mode 100644 index 00000000..0c0ca6a5 --- /dev/null +++ b/client/__tests__/reducers/genesetsUI.test.js @@ -0,0 +1,75 @@ +import genesetsUIReducer from "../../src/reducers/genesetsUI"; + +// Format: GeneSetsUI(state,action) + +const initialState = { + createGenesetModeActive: false, + isEditingGenesetName: false, + isAddingGenesToGeneset: false, +}; + +/* initial */ +describe("geneset UI states", () => { + test("initial state, some other action", () => { + expect( + genesetsUIReducer(undefined, { + type: "foo", + }) + ).toMatchObject(initialState); + }); + test("geneset: activate add new geneset mode", () => { + expect( + genesetsUIReducer(initialState, { + type: "geneset: activate add new geneset mode", + }) + ).toMatchObject({ + createGenesetModeActive: true, + isEditingGenesetName: false, + isAddingGenesToGeneset: false, + }); + }); + test("geneset: disable create geneset mode", () => { + expect( + genesetsUIReducer(undefined, { isEditingGenesetName: false }) + ).toMatchObject(initialState); + }); + + test("activate add new genes mode", () => { + expect( + genesetsUIReducer(undefined, { + type: "geneset: activate add new genes mode", + geneset: "a geneset name", + }) + ).toMatchObject({ + createGenesetModeActive: false, + isEditingGenesetName: false, + isAddingGenesToGeneset: "a geneset name", + }); + }); + test("disable create geneset mode", () => { + expect( + genesetsUIReducer(undefined, { + type: "geneset: disable create geneset mode", + }) + ).toMatchObject(initialState); + }); + test("activate rename geneset mode", () => { + expect( + genesetsUIReducer(undefined, { + type: "geneset: activate rename geneset mode", + data: "a geneset name", + }) + ).toMatchObject({ + createGenesetModeActive: false, + isEditingGenesetName: "a geneset name", + isAddingGenesToGeneset: false, + }); + }); + test("disable rename geneset mode", () => { + expect( + genesetsUIReducer(undefined, { + type: "geneset: disable rename geneset mode", + }) + ).toMatchObject(initialState); + }); +}); diff --git a/client/__tests__/util/typedCrossfilter/bitArray.test.js b/client/__tests__/util/typedCrossfilter/bitArray.test.js index 90ad4760..8878d413 100644 --- a/client/__tests__/util/typedCrossfilter/bitArray.test.js +++ b/client/__tests__/util/typedCrossfilter/bitArray.test.js @@ -1,5 +1,3 @@ -// jshint esversion: 6 - import BitArray from "../../../src/util/typedCrossfilter/bitArray"; const defaultTestLength = 8; diff --git a/client/__tests__/util/typedCrossfilter/positiveInterval.test.js b/client/__tests__/util/typedCrossfilter/positiveInterval.test.js index 1e9355fa..7c43e8a5 100644 --- a/client/__tests__/util/typedCrossfilter/positiveInterval.test.js +++ b/client/__tests__/util/typedCrossfilter/positiveInterval.test.js @@ -1,5 +1,3 @@ -// jshint esversion: 6 - // const PositiveIntervals = require("../../src/util/typedCrossfilter/positiveIntervals"); import PositiveIntervals from "../../../src/util/typedCrossfilter/positiveIntervals"; diff --git a/client/package-lock.json b/client/package-lock.json index 2cf552e3..3f792d31 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4154,6 +4154,21 @@ "react-transition-group": "^2.9.0", "resize-observer-polyfill": "^1.5.1", "tslib": "~1.10.0" + }, + "dependencies": { + "react-popper": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.10.tgz", + "integrity": "sha512-sZfwHtHCMst0L0G/c83/Y/K1f9fNWMEKsk/cGAor68rQBHB75WuDQ7k95tkce8QNaUHhg9uFXIJbZO0eWRvJbw==", + "requires": { + "@babel/runtime": "^7.1.2", + "@hypnosphi/create-react-context": "^0.3.1", + "popper.js": "^1.14.4", + "prop-types": "^15.6.1", + "typed-styles": "^0.0.7", + "warning": "^4.0.2" + } + } } }, "@blueprintjs/eslint-plugin": { @@ -4375,6 +4390,68 @@ "tslib": "~1.10.0" } }, + "@blueprintjs/popover2": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/popover2/-/popover2-0.3.0.tgz", + "integrity": "sha512-EtxKoaafkn3FRLKYh0V2CdtgPBgtSoOxIzrT7eZil5XBgEQiYap1lzyc/Rm+N+4yeW1+iOB6uY/3gdE4CRiPIg==", + "requires": { + "@blueprintjs/core": "^3.39.0", + "@popperjs/core": "^2.5.4", + "classnames": "^2.2", + "dom4": "^2.1.5", + "react-popper": "^2.2.4", + "tslib": "~1.13.0" + }, + "dependencies": { + "@blueprintjs/core": { + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-3.39.0.tgz", + "integrity": "sha512-Pl92Qvfo0iowvJ81hOxfOXgBUuqHdMgtYao7hCvhF5hvz8wYRfOr1GHChVsiD5NvpNmG2rtUTYqkfft06Tyj5A==", + "requires": { + "@blueprintjs/icons": "^3.24.0", + "@types/dom4": "^2.0.1", + "classnames": "^2.2", + "dom4": "^2.1.5", + "normalize.css": "^8.0.1", + "popper.js": "^1.16.1", + "react-lifecycles-compat": "^3.0.4", + "react-popper": "^1.3.7", + "react-transition-group": "^2.9.0", + "resize-observer-polyfill": "^1.5.1", + "tslib": "~1.13.0" + }, + "dependencies": { + "react-popper": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.10.tgz", + "integrity": "sha512-sZfwHtHCMst0L0G/c83/Y/K1f9fNWMEKsk/cGAor68rQBHB75WuDQ7k95tkce8QNaUHhg9uFXIJbZO0eWRvJbw==", + "requires": { + "@babel/runtime": "^7.1.2", + "@hypnosphi/create-react-context": "^0.3.1", + "popper.js": "^1.14.4", + "prop-types": "^15.6.1", + "typed-styles": "^0.0.7", + "warning": "^4.0.2" + } + } + } + }, + "@blueprintjs/icons": { + "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-3.24.0.tgz", + "integrity": "sha512-OvDDI5EUueS1Y3t594iS8LAGoHhLhYjC2GuN/01a85n+ASLSp0jf0/+uix2JeCOj41iTdRRCINbWuRwVQNNGPw==", + "requires": { + "classnames": "^2.2", + "tslib": "~1.13.0" + } + }, + "tslib": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", + "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==" + } + } + }, "@blueprintjs/select": { "version": "3.13.5", "resolved": "https://registry.npmjs.org/@blueprintjs/select/-/select-3.13.5.tgz", @@ -4503,6 +4580,15 @@ "@hapi/hoek": "^8.3.0" } }, + "@hypnosphi/create-react-context": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz", + "integrity": "sha512-V1klUed202XahrWJLLOT3EXNeCpFHCcJntdFGI15ntCwau+jfT386w7OFTMaCqOgXUH1fa0w/I1oZs+i/Rfr0A==", + "requires": { + "gud": "^1.0.0", + "warning": "^4.0.3" + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -5460,6 +5546,11 @@ } } }, + "@popperjs/core": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.8.4.tgz", + "integrity": "sha512-h0lY7g36rhjNV8KVHKS3/BEOgfsxu0AiRI8+ry5IFBGEsQFkpjxtcpVc9ndN8zrKUeMZXAWMc7eQMepfgykpxQ==" + }, "@sentry/cli": { "version": "1.55.0", "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-1.55.0.tgz", @@ -8229,15 +8320,6 @@ "sha.js": "^2.4.8" } }, - "create-react-context": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.3.0.tgz", - "integrity": "sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw==", - "requires": { - "gud": "^1.0.0", - "warning": "^4.0.3" - } - }, "cross-fetch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.5.tgz", @@ -8935,19 +9017,6 @@ "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", "dev": true }, - "deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", - "requires": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" - } - }, "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -8968,6 +9037,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, "requires": { "object-keys": "^1.0.12" } @@ -9454,6 +9524,7 @@ "version": "1.17.4", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.4.tgz", "integrity": "sha512-Ae3um/gb8F0mui/jPL+QiqmglkUsaQf7FwBEHYIFkztkneosu9imhqHpBzQ3h1vit8t5iQ74t6PEVvphBZiuiQ==", + "dev": true, "requires": { "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", @@ -9472,6 +9543,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, "requires": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -11303,7 +11375,8 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true }, "functional-red-black-tree": { "version": "1.0.1", @@ -11633,6 +11706,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -11645,7 +11719,8 @@ "has-symbols": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", - "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true }, "has-unicode": { "version": "2.0.1", @@ -12276,7 +12351,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dev": true, "requires": { "loose-envify": "^1.0.0" } @@ -12316,11 +12390,6 @@ } } }, - "is-arguments": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", - "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==" - }, "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -12344,7 +12413,8 @@ "is-callable": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", - "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==" + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", + "dev": true }, "is-ci": { "version": "2.0.0", @@ -12389,7 +12459,8 @@ "is-date-object": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", - "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==" + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true }, "is-descriptor": { "version": "0.1.6", @@ -12543,6 +12614,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "dev": true, "requires": { "has": "^1.0.3" } @@ -12589,6 +12661,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, "requires": { "has-symbols": "^1.0.1" } @@ -14215,6 +14288,11 @@ "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", "dev": true }, + "lodash.assign": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", + "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=" + }, "lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -14233,6 +14311,16 @@ "integrity": "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==", "dev": true }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, "lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -15180,54 +15268,14 @@ "object-inspect": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", - "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==" - }, - "object-is": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.2.tgz", - "integrity": "sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==", - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - }, - "dependencies": { - "es-abstract": { - "version": "1.17.6", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", - "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", - "requires": { - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1", - "is-callable": "^1.2.0", - "is-regex": "^1.1.0", - "object-inspect": "^1.7.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.0", - "string.prototype.trimend": "^1.0.1", - "string.prototype.trimstart": "^1.0.1" - } - }, - "is-callable": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz", - "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==" - }, - "is-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.0.tgz", - "integrity": "sha512-iI97M8KTWID2la5uYXlkbSDQIg4F6o1sYboZKKTDpnDQMLtUL86zxhgDet3Q2SriaYsyGqZ6Mn2SjbRKeLHdqw==", - "requires": { - "has-symbols": "^1.0.1" - } - } - } + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", + "dev": true }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true }, "object-visit": { "version": "1.0.1", @@ -15241,6 +15289,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, "requires": { "define-properties": "^1.1.2", "function-bind": "^1.1.1", @@ -16822,6 +16871,15 @@ "scheduler": "^0.19.1" } }, + "react-easy-emoji": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/react-easy-emoji/-/react-easy-emoji-1.4.0.tgz", + "integrity": "sha512-TcufijpuWKgYgzbySEBukNef+y0HI/4PAJ4gc9vb1CF7Q4CcAS2ZV8VMZk0ObtKKwJJfVgAHVt86nXWOed8QXg==", + "requires": { + "lodash.assign": "^4.0.8", + "string-replace-to-array": "^1.0.1" + } + }, "react-fast-compare": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", @@ -16866,17 +16924,19 @@ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, "react-popper": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.7.tgz", - "integrity": "sha512-nmqYTx7QVjCm3WUZLeuOomna138R1luC4EqkW3hxJUrAe+3eNz3oFCLYdnPwILfn0mX1Ew2c3wctrjlUMYYUww==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.4.tgz", + "integrity": "sha512-NacOu4zWupdQjVXq02XpTD3yFPSfg5a7fex0wa3uGKVkFK7UN6LvVxgcb+xYr56UCuWiNPMH20tntdVdJRwYew==", "requires": { - "@babel/runtime": "^7.1.2", - "create-react-context": "^0.3.0", - "deep-equal": "^1.1.1", - "popper.js": "^1.14.4", - "prop-types": "^15.6.1", - "typed-styles": "^0.0.7", + "react-fast-compare": "^3.0.1", "warning": "^4.0.2" + }, + "dependencies": { + "react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + } } }, "react-redux": { @@ -17090,6 +17150,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz", "integrity": "sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==", + "dev": true, "requires": { "define-properties": "^1.1.3", "es-abstract": "^1.17.0-next.1" @@ -18438,6 +18499,16 @@ "strip-ansi": "^6.0.0" } }, + "string-replace-to-array": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string-replace-to-array/-/string-replace-to-array-1.0.3.tgz", + "integrity": "sha1-yT66mZpe4k1zGuu69auja18Y978=", + "requires": { + "invariant": "^2.2.1", + "lodash.flatten": "^4.2.0", + "lodash.isstring": "^4.0.1" + } + }, "string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", @@ -18484,6 +18555,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", + "dev": true, "requires": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -18493,6 +18565,7 @@ "version": "1.17.6", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", + "dev": true, "requires": { "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", @@ -18510,12 +18583,14 @@ "is-callable": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz", - "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==" + "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==", + "dev": true }, "is-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.0.tgz", "integrity": "sha512-iI97M8KTWID2la5uYXlkbSDQIg4F6o1sYboZKKTDpnDQMLtUL86zxhgDet3Q2SriaYsyGqZ6Mn2SjbRKeLHdqw==", + "dev": true, "requires": { "has-symbols": "^1.0.1" } @@ -18526,6 +18601,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz", "integrity": "sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==", + "dev": true, "requires": { "define-properties": "^1.1.3", "function-bind": "^1.1.1" @@ -18535,6 +18611,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz", "integrity": "sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==", + "dev": true, "requires": { "define-properties": "^1.1.3", "function-bind": "^1.1.1" @@ -18544,6 +18621,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", + "dev": true, "requires": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -18553,6 +18631,7 @@ "version": "1.17.6", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", + "dev": true, "requires": { "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", @@ -18570,12 +18649,14 @@ "is-callable": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz", - "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==" + "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==", + "dev": true }, "is-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.0.tgz", "integrity": "sha512-iI97M8KTWID2la5uYXlkbSDQIg4F6o1sYboZKKTDpnDQMLtUL86zxhgDet3Q2SriaYsyGqZ6Mn2SjbRKeLHdqw==", + "dev": true, "requires": { "has-symbols": "^1.0.1" } diff --git a/client/package.json b/client/package.json index 29561f45..ea3a1988 100644 --- a/client/package.json +++ b/client/package.json @@ -40,6 +40,7 @@ "dependencies": { "@blueprintjs/core": "^3.30.0", "@blueprintjs/icons": "^3.19.0", + "@blueprintjs/popover2": "^0.3.0", "@blueprintjs/select": "^3.13.5", "abort-controller": "^3.0.0", "core-js": "^3.6.5", @@ -58,9 +59,11 @@ "react": "^16.13.1", "react-async": "^10.0.1", "react-dom": "^16.13.1", + "react-easy-emoji": "^1.4.0", "react-flip-toolkit": "^7.0.12", "react-helmet": "^5.2.1", "react-icons": "^3.10.0", + "react-popper": "^2.2.4", "react-redux": "^7.2.0", "redux": "^4.0.5", "redux-thunk": "^2.3.0", diff --git a/client/src/actions/annotation.js b/client/src/actions/annotation.js index 3d40fb2d..684fe7cb 100644 --- a/client/src/actions/annotation.js +++ b/client/src/actions/annotation.js @@ -393,6 +393,8 @@ export const saveGenesetsAction = () => async (dispatch, getState) => { // bail if gene sets not available, or in readonly mode. const { config } = state; + const { lastTid, genesets } = state.genesets; + const genesetsAreAvailable = config?.parameters?.["annotations_genesets"] ?? false; const genesetsReadonly = @@ -401,16 +403,18 @@ export const saveGenesetsAction = () => async (dispatch, getState) => { // our non-save was completed! return dispatch({ type: "autosave: genesets complete", - lastSavedGenesets: state.genesets, + lastSavedGenesets: genesets, }); } - const { lastTid, genesets: lastGenesets } = state.genesets; + dispatch({ + type: "autosave: genesets started", + }); /* Create the JSON OTA data structure */ const tid = (lastTid ?? 0) + 1; - const genesets = []; - for (const [name, gs] of lastGenesets) { + const genesetsOTA = []; + for (const [name, gs] of genesets) { const genes = []; for (const g of gs.genes.values()) { genes.push({ @@ -418,7 +422,7 @@ export const saveGenesetsAction = () => async (dispatch, getState) => { gene_description: g.geneDescription, }); } - genesets.push({ + genesetsOTA.push({ geneset_name: name, geneset_description: gs.genesetDescription, genes, @@ -426,7 +430,7 @@ export const saveGenesetsAction = () => async (dispatch, getState) => { } const ota = { tid, - genesets, + genesets: genesetsOTA, }; /* Save to server */ diff --git a/client/src/components/categorical/annoDialog.js b/client/src/components/annoDialog.js similarity index 89% rename from client/src/components/categorical/annoDialog.js rename to client/src/components/annoDialog.js index fdb07e77..e3842611 100644 --- a/client/src/components/categorical/annoDialog.js +++ b/client/src/components/annoDialog.js @@ -18,6 +18,8 @@ class AnnoDialog extends React.PureComponent { validationError, annoSelect, annoInput, + secondaryInstructions, + secondaryInput, handleCancel, handleSubmit, primaryButtonText, @@ -46,6 +48,12 @@ class AnnoDialog extends React.PureComponent { > {errorMessage}

+ {secondaryInstructions && ( +

+ {secondaryInstructions} +

+ )} + {secondaryInput || null} {annoSelect || null} diff --git a/client/src/components/app.js b/client/src/components/app.js index 4de58f1c..eb46d3a6 100644 --- a/client/src/components/app.js +++ b/client/src/components/app.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 import React from "react"; import Helmet from "react-helmet"; import { connect } from "react-redux"; diff --git a/client/src/components/autosave/filenameDialog.js b/client/src/components/autosave/filenameDialog.js index 3ff7e500..b1fde95b 100644 --- a/client/src/components/autosave/filenameDialog.js +++ b/client/src/components/autosave/filenameDialog.js @@ -17,6 +17,9 @@ import { auth: state.config?.authentication, userInfo: state.userInfo, writableCategoriesEnabled: state.config?.parameters?.annotations ?? false, + writableGenesetsEnabled: !( + state.config?.parameters?.["annotations_genesets_readonly"] ?? true + ), })) class FilenameDialog extends React.Component { constructor(props) { @@ -95,13 +98,14 @@ class FilenameDialog extends React.Component { render() { const { writableCategoriesEnabled, + writableGenesetsEnabled, annotations, idhash, userInfo, } = this.props; const { filenameText } = this.state; - return writableCategoriesEnabled && + return (writableCategoriesEnabled || writableGenesetsEnabled) && annotations.promptForFilename && !annotations.dataCollectionNameIsReadOnly && !annotations.dataCollectionName && diff --git a/client/src/components/autosave/index.js b/client/src/components/autosave/index.js index 18b5f152..12cd4982 100644 --- a/client/src/components/autosave/index.js +++ b/client/src/components/autosave/index.js @@ -67,7 +67,7 @@ class Autosave extends React.Component { needToSaveGenesets = () => { /* return true if we need to save gene ses, false if we do not */ const { genesets, lastSavedGenesets } = this.props; - return genesets.initialized && genesets !== lastSavedGenesets; + return genesets.initialized && genesets.genesets !== lastSavedGenesets; }; needToSave() { diff --git a/client/src/components/brushableHistogram/error.js b/client/src/components/brushableHistogram/error.js new file mode 100644 index 00000000..13a015d5 --- /dev/null +++ b/client/src/components/brushableHistogram/error.js @@ -0,0 +1,17 @@ +import React from "react"; +import * as globals from "../../globals"; + +const ErrorLoading = ({ displayName, zebra }) => { + return ( +
+ {`Failure loading ${displayName}`} +
+ ); +}; + +export default ErrorLoading; diff --git a/client/src/components/brushableHistogram/footer.js b/client/src/components/brushableHistogram/footer.js new file mode 100644 index 00000000..39e800f2 --- /dev/null +++ b/client/src/components/brushableHistogram/footer.js @@ -0,0 +1,90 @@ +import React from "react"; + +const HistogramFooter = React.memo( + ({ + displayName, + hideRanges, + rangeMin, + rangeMax, + rangeColorMin, + rangeColorMax, + logFoldChange, + pvalAdj, + isObs, + }) => { + /* + Footer of each histogram. Will render range, title, and optionally + differential expression info. + + Required props: + * displayName - the displayName, aka "n_genes", "FOXP2", etc. + * hideRanges - true/false, enables/disable rendering of ranges + * range - length two array, [min, max], containing the range values to display + * rangeColor - length two array, [mincolor, maxcolor], each a CSS color + * logFoldChange - lfc to display, optional. + * pValue - pValue to display, optional. + */ + return ( +
+
+ + min {rangeMin.toPrecision(4)} + + + {isObs ? displayName : null} + +
+ : {rangeMin} +
+ + max {rangeMax.toPrecision(4)} + +
+ + {logFoldChange && pvalAdj ? ( +
+ + log fold change: + {` ${logFoldChange.toPrecision(4)}`} + + + p-value (adj): + {pvalAdj < 0.0001 ? " < 0.0001" : ` ${pvalAdj.toFixed(4)}`} + +
+ ) : null} +
+ ); + } +); + +export default HistogramFooter; diff --git a/client/src/components/brushableHistogram/header.js b/client/src/components/brushableHistogram/header.js new file mode 100644 index 00000000..b53684c1 --- /dev/null +++ b/client/src/components/brushableHistogram/header.js @@ -0,0 +1,100 @@ +import React, { useCallback } from "react"; +import { Button, ButtonGroup, Tooltip, Icon } from "@blueprintjs/core"; +import { IconNames } from "@blueprintjs/icons"; +import * as globals from "../../globals"; + +const HistogramHeader = React.memo( + ({ + fieldId, + isColorBy, + onColorByClick, + onRemoveClick, + isScatterPlotX, + isScatterPlotY, + onScatterPlotXClick, + onScatterPlotYClick, + isObs, + }) => { + /* + Render the toolbar for the histogram. Props: + * fieldId - field identifier, used for various IDs + * isColorBy - true/false, is this the current color-by + * onColorByClick - color-by click handler + * onRemoveClick - optional handler for remove. Button will not render if not defined. + * isScatterPlotX - optional, true/false if currently the X scatterplot field + * isScatterPlotY - optional, true/false if currently the Y scatterplot field + * onScatterPlotXClick - optional, handler for scatterPlot X button. + * onScatterPlotYClick - optional, handler for scatterPlot X button. + + Scatterplot controls will not render if either handler unspecified. + */ + + const memoizedColorByCallback = useCallback( + () => onColorByClick(fieldId, isObs), + [fieldId, isObs] + ); + + return ( +
+ {onScatterPlotXClick && onScatterPlotYClick ? ( + + + + + + + + ) : null} + {onRemoveClick ? ( + + ) : null} + +
+ ); + } +); + +export default HistogramHeader; diff --git a/client/src/components/brushableHistogram/histogram.js b/client/src/components/brushableHistogram/histogram.js new file mode 100644 index 00000000..e49eb6e4 --- /dev/null +++ b/client/src/components/brushableHistogram/histogram.js @@ -0,0 +1,185 @@ +import React, { useEffect, useRef, useState } from "react"; +import { interpolateCool } from "d3-scale-chromatic"; +import * as d3 from "d3"; + +import maybeScientific from "../../util/maybeScientific"; +import clamp from "../../util/clamp"; + +const Histogram = ({ + field, + fieldForId, + display, + histogram, + width, + height, + onBrush, + onBrushEnd, + margin, + isColorBy, + selectionRange, + mini, +}) => { + const svgRef = useRef(null); + const [brush, setBrush] = useState(null); + + useEffect(() => { + /* + Create the d3 histogram + */ + const { marginLeft, marginRight, marginBottom, marginTop } = margin; + const { x, y, bins, binStart, binEnd, binWidth } = histogram; + const svg = d3.select(svgRef.current); + const binPadding = mini ? 0 : -1; + const defaultBarColor = mini ? "black" : "#bbb"; + + /* Remove everything */ + svg.selectAll("*").remove(); + + /* Set margins within the SVG */ + const container = svg + .attr("width", width + marginLeft + marginRight) + .attr("height", height + marginTop + marginBottom) + .append("g") + .attr("class", "histogram-container") + .attr("transform", `translate(${marginLeft},${marginTop})`); + + const colorScale = d3 + .scaleSequential(interpolateCool) + .domain([0, bins.length]); + + const histogramScale = d3 + .scaleLinear() + .domain(x.domain()) + .range([ + colorScale.domain()[1], + colorScale.domain()[0], + ]); /* we flip this to make colors dark if high in the color scale */ + + if (binWidth > 0) { + /* BINS */ + container + .insert("g", "*") + .selectAll("rect") + .data(bins) + .enter() + .append("rect") + .attr("x", (d, i) => x(binStart(i)) + 1) + .attr("y", (d) => y(d)) + .attr("width", (d, i) => x(binEnd(i)) - x(binStart(i)) - binPadding) + .attr("height", (d) => y(0) - y(d)) + .style( + "fill", + isColorBy + ? (d, i) => colorScale(histogramScale(binStart(i))) + : defaultBarColor + ); + } + + if (!mini) { + // BRUSH + // Note the brushable area is bounded by the data on three sides, but goes down to cover the x-axis + const brushX = d3 + .brushX() + .extent([ + [x.range()[0], y.range()[1]], + [x.range()[1], marginTop + height + marginBottom], + ]) + /* + emit start so that the Undoable history can save an undo point + upon drag start, and ignore the subsequent intermediate drag events. + */ + .on("start", onBrush(field, x.invert, "start")) + .on("brush", onBrush(field, x.invert, "brush")) + .on("end", onBrushEnd(field, x.invert)); + + const brushXselection = container + .insert("g") + .attr("class", "brush") + .attr("data-testid", `${svgRef.current.dataset.testid}-brushable-area`) + .call(brushX); + + /* X AXIS */ + container + .insert("g") + .attr("class", "axis axis--x") + .attr("transform", `translate(0,${marginTop + height})`) + .call( + d3 + .axisBottom(x) + .ticks(4) + .tickFormat(d3.format(maybeScientific(x))) + ); + + /* Y AXIS */ + container + .insert("g") + .attr("class", "axis axis--y") + .attr("transform", `translate(${marginLeft + width},0)`) + .call( + d3 + .axisRight(y) + .ticks(3) + .tickFormat( + d3.format( + y.domain().some((n) => Math.abs(n) >= 10000) ? ".0e" : "," + ) + ) + ); + + /* axis style */ + svg.selectAll(".axis text").style("fill", "rgb(80,80,80)"); + svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)"); + svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)"); + + setBrush({ brushX, brushXselection }); + } + }, [histogram, isColorBy]); + + useEffect(() => { + /* + paint/update selection brush + */ + if (!brush) return; + const { brushX, brushXselection } = brush; + const selection = d3.brushSelection(brushXselection.node()); + if (!selectionRange && selection) { + /* no active selection - clear brush */ + brushXselection.call(brushX.move, null); + } else if (selectionRange) { + const { x, domain } = histogram; + const [min, max] = domain; + const x0 = x(clamp(selectionRange[0], [min, max])); + const x1 = x(clamp(selectionRange[1], [min, max])); + if (!selection) { + /* there is an active selection, but no brush - set the brush */ + brushXselection.call(brushX.move, [x0, x1]); + } else { + /* there is an active selection and a brush - make sure they match */ + const moveDeltaThreshold = 1; + const dX0 = Math.abs(x0 - selection[0]); + const dX1 = Math.abs(x1 - selection[1]); + /* + only update the brush if it is grossly incorrect, + as defined by the moveDeltaThreshold + */ + if (dX0 > moveDeltaThreshold || dX1 > moveDeltaThreshold) { + brushXselection.call(brushX.move, [x0, x1]); + } + } + } + }, [brush, selectionRange]); + + return ( + + ); +}; + +export default Histogram; diff --git a/client/src/components/brushableHistogram/index.js b/client/src/components/brushableHistogram/index.js index e86e49ff..ff0c983e 100644 --- a/client/src/components/brushableHistogram/index.js +++ b/client/src/components/brushableHistogram/index.js @@ -1,442 +1,17 @@ -/* -https://bl.ocks.org/mbostock/4341954 -https://bl.ocks.org/mbostock/34f08d5e11952a80609169b7917d4172 -https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771 -https://bl.ocks.org/mbostock/3019563 -*/ -import React, { useEffect, useRef, useState, useCallback } from "react"; -import { Button, ButtonGroup, Icon, Tooltip } from "@blueprintjs/core"; +import React from "react"; import { connect } from "react-redux"; import * as d3 from "d3"; -import { interpolateCool } from "d3-scale-chromatic"; import Async from "react-async"; import memoize from "memoize-one"; -import { IconNames } from "@blueprintjs/icons"; import * as globals from "../../globals"; import actions from "../../actions"; import { histogramContinuous } from "../../util/dataframe/histogram"; import { makeContinuousDimensionName } from "../../util/nameCreators"; -import significantDigits from "../../util/significantDigits"; - -function clamp(val, rng) { - return Math.max(Math.min(val, rng[1]), rng[0]); -} - -function maybeScientific(x) { - let format = ","; - const _ticks = x.ticks(4); - - if (x.domain().some((n) => Math.abs(n) >= 10000)) { - /* - heuristic: if the last tick d3 wants to render has one significant - digit ie., 2000, render 2e+3, but if it's anything else ie., 42000000 render - 4.20e+n - */ - format = significantDigits(_ticks[_ticks.length - 1]) === 1 ? ".0e" : ".2e"; - } - - return format; -} - -const StillLoading = ({ zebra, displayName }) => { - /* - Render a loading indicator for the field. - */ - return ( -
-
-
-
- {displayName} -
-
-
-
-
- ); -}; - -const ErrorLoading = ({ displayName, error, zebra }) => { - console.log(error); // log to console as this is unexpected - return ( -
- {`Failure loading ${displayName}`} -
- ); -}; - -const HistogramFooter = React.memo( - ({ - displayName, - hideRanges, - rangeMin, - rangeMax, - rangeColorMin, - rangeColorMax, - logFoldChange, - pvalAdj, - }) => { - /* - Footer of each histogram. Will render range, title, and optionally - differential expression info. - - Required props: - * displayName - the displayName, aka "n_genes", "FOXP2", etc. - * hideRanges - true/false, enables/disable rendering of ranges - * range - length two array, [min, max], containing the range values to display - * rangeColor - length two array, [mincolor, maxcolor], each a CSS color - * logFoldChange - lfc to display, optional. - * pValue - pValue to display, optional. - */ - return ( -
-
- - min {rangeMin.toPrecision(4)} - - - {displayName} - -
- : {rangeMin} -
- - max {rangeMax.toPrecision(4)} - -
- - {logFoldChange !== undefined && pvalAdj !== undefined ? ( -
- - log fold change: - {` ${logFoldChange.toPrecision(4)}`} - - - p-value (adj): - {pvalAdj < 0.0001 ? " < 0.0001" : ` ${pvalAdj.toFixed(4)}`} - -
- ) : null} -
- ); - } -); - -const HistogramHeader = React.memo( - ({ - fieldId, - isColorBy, - onColorByClick, - onRemoveClick, - isScatterPlotX, - isScatterPlotY, - onScatterPlotXClick, - onScatterPlotYClick, - isObs, - }) => { - /* - Render the toolbar for the histogram. Props: - * fieldId - field identifier, used for various IDs - * isColorBy - true/false, is this the current color-by - * onColorByClick - color-by click handler - * onRemoveClick - optional handler for remove. Button will not render if not defined. - * isScatterPlotX - optional, true/false if currently the X scatterplot field - * isScatterPlotY - optional, true/false if currently the Y scatterplot field - * onScatterPlotXClick - optional, handler for scatterPlot X button. - * onScatterPlotYClick - optional, handler for scatterPlot X button. - - Scatterplot controls will not render if either handler unspecified. - */ - - const memoizedColorByCallback = useCallback( - () => onColorByClick(fieldId, isObs), - [fieldId, isObs] - ); - - return ( -
- {onScatterPlotXClick && onScatterPlotYClick ? ( - - - - - - - - ) : null} - {onRemoveClick ? ( - - ) : null} - -
- ); - } -); - -const Histogram = ({ - field, - fieldForId, - display, - histogram, - width, - height, - onBrush, - onBrushEnd, - margin, - isColorBy, - selectionRange, -}) => { - const svgRef = useRef(null); - const [brush, setBrush] = useState(null); - - useEffect(() => { - /* - Create the d3 histogram - */ - const { marginLeft, marginRight, marginBottom, marginTop } = margin; - const { x, y, bins, binStart, binEnd, binWidth } = histogram; - const svg = d3.select(svgRef.current); - - /* Remove everything */ - svg.selectAll("*").remove(); - - /* Set margins within the SVG */ - const container = svg - .attr("width", width + marginLeft + marginRight) - .attr("height", height + marginTop + marginBottom) - .append("g") - .attr("class", "histogram-container") - .attr("transform", `translate(${marginLeft},${marginTop})`); - - const colorScale = d3 - .scaleSequential(interpolateCool) - .domain([0, bins.length]); - - const histogramScale = d3 - .scaleLinear() - .domain(x.domain()) - .range([ - colorScale.domain()[1], - colorScale.domain()[0], - ]); /* we flip this to make colors dark if high in the color scale */ - - if (binWidth > 0) { - /* BINS */ - container - .insert("g", "*") - .selectAll("rect") - .data(bins) - .enter() - .append("rect") - .attr("x", (d, i) => x(binStart(i)) + 1) - .attr("y", (d) => y(d)) - .attr("width", (d, i) => x(binEnd(i)) - x(binStart(i)) - 1) - .attr("height", (d) => y(0) - y(d)) - .style( - "fill", - isColorBy ? (d, i) => colorScale(histogramScale(binStart(i))) : "#bbb" - ); - } - - // BRUSH - // Note the brushable area is bounded by the data on three sides, but goes down to cover the x-axis - const brushX = d3 - .brushX() - .extent([ - [x.range()[0], y.range()[1]], - [x.range()[1], marginTop + height + marginBottom], - ]) - /* - emit start so that the Undoable history can save an undo point - upon drag start, and ignore the subsequent intermediate drag events. - */ - .on("start", onBrush(field, x.invert, "start")) - .on("brush", onBrush(field, x.invert, "brush")) - .on("end", onBrushEnd(field, x.invert)); - - const brushXselection = container - .insert("g") - .attr("class", "brush") - .attr("data-testid", `${svgRef.current.dataset.testid}-brushable-area`) - .call(brushX); - - /* X AXIS */ - container - .insert("g") - .attr("class", "axis axis--x") - .attr("transform", `translate(0,${marginTop + height})`) - .call( - d3 - .axisBottom(x) - .ticks(4) - .tickFormat(d3.format(maybeScientific(x))) - ); - - /* Y AXIS */ - container - .insert("g") - .attr("class", "axis axis--y") - .attr("transform", `translate(${marginLeft + width},0)`) - .call( - d3 - .axisRight(y) - .ticks(3) - .tickFormat( - d3.format( - y.domain().some((n) => Math.abs(n) >= 10000) ? ".0e" : "," - ) - ) - ); - - /* axis style */ - svg.selectAll(".axis text").style("fill", "rgb(80,80,80)"); - svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)"); - svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)"); - - setBrush({ brushX, brushXselection }); - }, [histogram, isColorBy]); - - useEffect(() => { - /* - paint/update selection brush - */ - if (!brush) return; - const { brushX, brushXselection } = brush; - const selection = d3.brushSelection(brushXselection.node()); - if (!selectionRange && selection) { - /* no active selection - clear brush */ - brushXselection.call(brushX.move, null); - } else if (selectionRange) { - const { x, domain } = histogram; - const [min, max] = domain; - const x0 = x(clamp(selectionRange[0], [min, max])); - const x1 = x(clamp(selectionRange[1], [min, max])); - if (!selection) { - /* there is an active selection, but no brush - set the brush */ - brushXselection.call(brushX.move, [x0, x1]); - } else { - /* there is an active selection and a brush - make sure they match */ - const moveDeltaThreshold = 1; - const dX0 = Math.abs(x0 - selection[0]); - const dX1 = Math.abs(x1 - selection[1]); - /* - only update the brush if it is grossly incorrect, - as defined by the moveDeltaThreshold - */ - if (dX0 > moveDeltaThreshold || dX1 > moveDeltaThreshold) { - brushXselection.call(brushX.move, [x0, x1]); - } - } - } - }, [brush, selectionRange]); - - return ( - - ); -}; +import HistogramHeader from "./header"; +import Histogram from "./histogram"; +import HistogramFooter from "./footer"; +import StillLoading from "./loading"; +import ErrorLoading from "./error"; @connect((state, ownProps) => { const { isObs, isUserDefined, isDiffExp, field } = ownProps; @@ -472,14 +47,25 @@ class HistogramBrush extends React.PureComponent { const marginRight = 54; // space for Y axis & labels const marginBottom = 25; // space for X axis & labels const marginTop = 3; - this.margin = { - marginLeft, - marginRight, - marginBottom, - marginTop, + + this.state = { + margin: { + marginLeft, + marginRight, + marginBottom, + marginTop, + }, + width: 340 - marginLeft - marginRight, + height: 135 - marginTop - marginBottom, + marginMini: { + marginLeft: 0, // Space for 0 tick label on X axis + marginRight: 0, // space for Y axis & labels + marginBottom: 0, // space for X axis & labels + marginTop: 0, + }, + widthMini: 120, + heightMini: 15, }; - this.width = 340 - marginLeft - marginRight; - this.height = 135 - marginTop - marginBottom; } onBrush = (selection, x, eventType) => { @@ -609,6 +195,14 @@ class HistogramBrush extends React.PureComponent { fetchAsyncProps = async () => { const { annoMatrix } = this.props; + const { + margin, + width, + height, + marginMini, + widthMini, + heightMini, + } = this.state; const { isClipped } = annoMatrix; const query = this.createQuery(); @@ -636,11 +230,12 @@ class HistogramBrush extends React.PureComponent { : globals.blue, ]; - const histogram = this.calcHistogramCache( + const histogram = this.calcHistogramCache(column, margin, width, height); + const miniHistogram = this.calcHistogramCache( column, - this.margin, - this.width, - this.height + marginMini, + widthMini, + heightMini ); const isSingleValue = summary.min === summary.max; @@ -654,6 +249,7 @@ class HistogramBrush extends React.PureComponent { return { histogram, + miniHistogram, range, unclippedRange, unclippedRangeColor, @@ -667,13 +263,16 @@ class HistogramBrush extends React.PureComponent { /* recalculate expensive stuff, notably bins, summaries, etc. */ - const histogramCache = {}; - const summary = col.summarize(); + const histogramCache = {}; /* maybe change this so that it computes ... */ + const summary = col.summarize(); /* this is memoized, so it's free the second time you call it */ const { min: domainMin, max: domainMax } = summary; const numBins = 40; - const { marginTop, marginLeft } = margin; + const { marginTop, marginLeft } = margin; /* changes with mini */ - histogramCache.domain = [domainMin, domainMax]; + histogramCache.domain = [ + domainMin, + domainMax, + ]; /* doesn't change with mini */ histogramCache.x = d3 .scaleLinear() @@ -683,7 +282,7 @@ class HistogramBrush extends React.PureComponent { histogramCache.bins = histogramContinuous(col, numBins, [ domainMin, domainMax, - ]); + ]); /* memoized */ histogramCache.binWidth = (domainMax - domainMin) / numBins; histogramCache.binStart = (i) => domainMin + i * histogramCache.binWidth; @@ -733,7 +332,16 @@ class HistogramBrush extends React.PureComponent { zebra, continuousSelectionRange, isObs, + mini, } = this.props; + const { + margin, + width, + height, + marginMini, + widthMini, + heightMini, + } = this.state; const fieldForId = field.replace(/\s/g, "_"); const showScatterPlot = isDiffExp || isUserDefined; @@ -761,48 +369,56 @@ class HistogramBrush extends React.PureComponent { : "histogram-continuous-metadata" } style={{ - padding: globals.leftSidebarSectionPadding, + padding: mini ? 0 : globals.leftSidebarSectionPadding, backgroundColor: zebra ? globals.lightestGrey : "white", }} > - + {!mini && isObs ? ( + + ) : null} - + {!mini ? ( + + ) : null}
) : null } diff --git a/client/src/components/brushableHistogram/loading.js b/client/src/components/brushableHistogram/loading.js new file mode 100644 index 00000000..7a195c78 --- /dev/null +++ b/client/src/components/brushableHistogram/loading.js @@ -0,0 +1,42 @@ +import React from "react"; +import { Button } from "@blueprintjs/core"; + +import * as globals from "../../globals"; + +const StillLoading = ({ zebra, displayName }) => { + /* + Render a loading indicator for the field. + */ + return ( +
+
+
+
+ {displayName} +
+
+
+
+
+ ); +}; + +export default StillLoading; diff --git a/client/src/components/categorical/category/annoDialogAddLabel.js b/client/src/components/categorical/category/annoDialogAddLabel.js index 7de242d0..80047f21 100644 --- a/client/src/components/categorical/category/annoDialogAddLabel.js +++ b/client/src/components/categorical/category/annoDialogAddLabel.js @@ -1,7 +1,7 @@ import React from "react"; import { connect } from "react-redux"; -import AnnoDialog from "../annoDialog"; -import LabelInput from "../labelInput"; +import AnnoDialog from "../../annoDialog"; +import LabelInput from "../../labelInput"; import { labelPrompt, isLabelErroneous } from "../labelUtil"; import actions from "../../../actions"; diff --git a/client/src/components/categorical/category/annoDialogEditCategoryName.js b/client/src/components/categorical/category/annoDialogEditCategoryName.js index 832d5ae3..d90d7040 100644 --- a/client/src/components/categorical/category/annoDialogEditCategoryName.js +++ b/client/src/components/categorical/category/annoDialogEditCategoryName.js @@ -1,7 +1,7 @@ import React from "react"; import { connect } from "react-redux"; -import AnnoDialog from "../annoDialog"; -import LabelInput from "../labelInput"; +import AnnoDialog from "../../annoDialog"; +import LabelInput from "../../labelInput"; import { labelPrompt } from "../labelUtil"; import { AnnotationsHelpers } from "../../../util/stateManager"; diff --git a/client/src/components/categorical/category/annoMenuCategory.js b/client/src/components/categorical/category/annoMenuCategory.js index 4606f75a..706ff63a 100644 --- a/client/src/components/categorical/category/annoMenuCategory.js +++ b/client/src/components/categorical/category/annoMenuCategory.js @@ -9,7 +9,9 @@ import { Tooltip, Icon, PopoverInteractionKind, + Intent, } from "@blueprintjs/core"; +import { IconNames } from "@blueprintjs/icons"; import * as globals from "../../../globals"; import actions from "../../../actions"; @@ -89,8 +91,8 @@ class AnnoMenuCategory extends React.PureComponent { text={editText} /> + {writableCategoriesEnabled ? ( +
+ + + Create new category + + +
+ ) : null} + {/* READ ONLY CATEGORICAL FIELDS */} {/* this is duplicative but flat, could be abstracted */} {allCategoryNames.map((catName) => @@ -209,33 +237,6 @@ class Categories extends React.Component { /> ) : null )} - - {writableCategoriesEnabled ? ( - - - Create new category - - - ) : null}
); } diff --git a/client/src/components/categorical/value/index.js b/client/src/components/categorical/value/index.js index a9e682dc..628118fb 100644 --- a/client/src/components/categorical/value/index.js +++ b/client/src/components/categorical/value/index.js @@ -14,8 +14,8 @@ import { } from "@blueprintjs/core"; import * as globals from "../../../globals"; import styles from "../categorical.css"; -import AnnoDialog from "../annoDialog"; -import LabelInput from "../labelInput"; +import AnnoDialog from "../../annoDialog"; +import LabelInput from "../../labelInput"; import Truncate from "../../util/truncate"; import { AnnotationsHelpers } from "../../../util/stateManager"; @@ -25,8 +25,8 @@ import MiniHistogram from "../../miniHistogram"; import MiniStackedBar from "../../miniStackedBar"; import { CategoryCrossfilterContext } from "../categoryContext"; -const VALUE_HEIGHT = 11; -const CHART_WIDTH = 100; +const STACKED_BAR_HEIGHT = 11; +const STACKED_BAR_WIDTH = 100; /* this is defined outside of the class so we can use it in connect() */ function _currentLabelAsString(ownProps) { @@ -410,7 +410,7 @@ class CategoryValue extends React.Component { label, colorTable, schema, - CHART_WIDTH + STACKED_BAR_WIDTH ) ?? {}; if (!domainValues || !scale || !domain || !occupancy) { @@ -426,8 +426,8 @@ class CategoryValue extends React.Component { domain, occupancy, }} - height={VALUE_HEIGHT} - width={CHART_WIDTH} + height={STACKED_BAR_HEIGHT} + width={STACKED_BAR_WIDTH} /> ); }; @@ -460,8 +460,8 @@ class CategoryValue extends React.Component { colorAccessor, colorData, label, - CHART_WIDTH, - VALUE_HEIGHT + STACKED_BAR_WIDTH, + STACKED_BAR_HEIGHT ) ?? {}; // if createHistogramBins returns empty object assign null to deconstructed if (!xScale || !yScale || !bins) return null; @@ -476,8 +476,8 @@ class CategoryValue extends React.Component { }} obsOrVarContinuousFieldDisplayName={colorAccessor} domainLabel={label} - height={VALUE_HEIGHT} - width={CHART_WIDTH} + height={STACKED_BAR_HEIGHT} + width={STACKED_BAR_WIDTH} /> ); }; @@ -534,7 +534,7 @@ class CategoryValue extends React.Component { colorAccessor && !isColorBy ? globals.leftSidebarWidth - otherElementsWidth - - CHART_WIDTH - + STACKED_BAR_WIDTH - CHART_MARGIN : globals.leftSidebarWidth - otherElementsWidth; @@ -677,8 +677,8 @@ class CategoryValue extends React.Component { display={isColorBy && categoryValueIndices ? "auto" : "none"} style={{ marginLeft: 5, - width: VALUE_HEIGHT, - height: VALUE_HEIGHT, + width: 15, + height: 15, backgroundColor: isColorBy && categoryValueIndices ? colorScale(categoryValueIndices.get(label)) @@ -739,7 +739,7 @@ class CategoryValue extends React.Component { ) : null} {displayString !== globals.unassignedCategoryLabel ? ( { diff --git a/client/src/components/continuous/util.js b/client/src/components/continuous/util.js index 40462c6c..cca3b91c 100644 --- a/client/src/components/continuous/util.js +++ b/client/src/components/continuous/util.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 import _ from "lodash"; import * as d3 from "d3"; diff --git a/client/src/components/continuousLegend/index.js b/client/src/components/continuousLegend/index.js index 38de34f1..5087ab59 100644 --- a/client/src/components/continuousLegend/index.js +++ b/client/src/components/continuousLegend/index.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 import React from "react"; import { connect } from "react-redux"; import * as d3 from "d3"; @@ -10,7 +9,6 @@ import { } from "../../util/stateManager/colorHelpers"; // create continuous color legend -// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f const continuous = (selectorId, colorScale, colorAccessor) => { const legendHeight = 200; const legendWidth = 80; diff --git a/client/src/components/framework/container.js b/client/src/components/framework/container.js index a5e161f6..db9e2e19 100644 --- a/client/src/components/framework/container.js +++ b/client/src/components/framework/container.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 import React from "react"; function Container(props) { diff --git a/client/src/components/framework/layout.js b/client/src/components/framework/layout.js index 6b0edb29..0ee27913 100644 --- a/client/src/components/framework/layout.js +++ b/client/src/components/framework/layout.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 import React from "react"; import * as globals from "../../globals"; diff --git a/client/src/components/geneExpression/gene.js b/client/src/components/geneExpression/gene.js new file mode 100644 index 00000000..5ea3f1c7 --- /dev/null +++ b/client/src/components/geneExpression/gene.js @@ -0,0 +1,188 @@ +import React from "react"; +import { connect } from "react-redux"; + +import { AnchorButton, Icon } from "@blueprintjs/core"; +import Truncate from "../util/truncate"; +import HistogramBrush from "../brushableHistogram"; + +import * as globals from "../../globals"; +import actions from "../../actions"; + +@connect((state, ownProps) => { + const { gene } = ownProps; + + return { + isColorAccessor: state.colors.colorAccessor === gene, + isScatterplotXXaccessor: state.controls.scatterplotXXaccessor === gene, + isScatterplotYYaccessor: state.controls.scatterplotYYaccessor === gene, + }; +}) +class Gene extends React.Component { + constructor(props) { + super(props); + this.state = { + geneIsExpanded: false, + }; + } + + onColorChangeClick = () => { + const { dispatch, gene } = this.props; + dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(gene)); + }; + + handleGeneExpandClick = () => { + const { geneIsExpanded } = this.state; + this.setState({ geneIsExpanded: !geneIsExpanded }); + }; + + handleSetGeneAsScatterplotX = () => { + const { dispatch, gene } = this.props; + dispatch({ + type: "set scatterplot x", + data: gene, + }); + }; + + handleSetGeneAsScatterplotY = () => { + const { dispatch, gene } = this.props; + dispatch({ + type: "set scatterplot y", + data: gene, + }); + }; + + handleDeleteGeneFromSet = () => { + const { dispatch, gene, geneset } = this.props; + + dispatch({ + type: "geneset: delete genes", + genesetName: geneset, + geneSymbols: [gene], + }); + }; + + render() { + const { + gene, + isColorAccessor, + isScatterplotXXaccessor, + isScatterplotYYaccessor, + isDiffexp, + } = this.props; + const { geneIsExpanded } = this.state; + const genesetNameLengthVisible = 310; /* this magic number determines how much of a long geneset name we see */ + return ( +
+
+
{}} + style={{ + cursor: "pointer", + display: "flex", + justifyContent: "space-between", + width: "100%", + }} + > +
+ + + + {gene} + + +
+ {!geneIsExpanded ? ( + + ) : null} +
+
+ {!isDiffexp ? ( + } + /> + ) : null} + + x + + + y + + } + style={{ marginRight: 2 }} + /> + } + /> +
+
+ {geneIsExpanded ? : null} +
+ ); + } +} + +export default Gene; diff --git a/client/src/components/geneExpression/geneSet.js b/client/src/components/geneExpression/geneSet.js new file mode 100644 index 00000000..deb6b3aa --- /dev/null +++ b/client/src/components/geneExpression/geneSet.js @@ -0,0 +1,183 @@ +import React from "react"; +import _ from "lodash"; +import { connect } from "react-redux"; +import { Tooltip, Position, Switch } from "@blueprintjs/core"; +import { FaChevronRight, FaChevronDown } from "react-icons/fa"; +import actions from "../../actions"; +import Gene from "./gene"; +import { memoize } from "../../util/dataframe/util"; +import Truncate from "../util/truncate"; +import * as globals from "../../globals"; +import GenesetMenus from "./menus/genesetMenus"; + +@connect((state, ownProps) => { + return { + world: state.world, + userDefinedGenes: state.controls.userDefinedGenes, + userDefinedGenesLoading: state.controls.userDefinedGenesLoading, + isColorAccessor: state.colors.colorAccessor === ownProps.setName, + }; +}) +class GeneSet extends React.Component { + _memoGenesToUpper = memoize(this._genesToUpper, (arr) => arr); + + constructor(props) { + super(props); + this.state = { + isOpen: false, + toggleSummaryHisto: false, + }; + } + + _genesToUpper = (listGenes) => { + // Has to be a Map to preserve index + const upperGenes = new Map(); + for (let i = 0, { length } = listGenes; i < length; i += 1) { + upperGenes.set(listGenes[i].toUpperCase(), i); + } + + return upperGenes; + }; + + fetchGenes = () => { + const { world, dispatch, setGenes } = this.props; + const varIndexName = world.schema.annotations.var.index; + + const worldGenes = world.varAnnotations.col(varIndexName).asArray(); + + const upperGenes = this._genesToUpper(setGenes); + const upperWorldGenes = this._memoGenesToUpper(worldGenes); + + dispatch({ type: "bulk user defined gene start" }); + + Promise.all( + [...upperGenes.keys()].map((upperGene) => { + const indexOfGene = upperWorldGenes.get(upperGene); + + return dispatch( + actions.requestUserDefinedGene(worldGenes[indexOfGene]) + ); + }) + ).then( + () => dispatch({ type: "bulk user defined gene complete" }), + () => dispatch({ type: "bulk user defined gene error" }) + ); + + return undefined; + }; + + onGenesetMenuClick = () => { + const { isOpen } = this.state; + this.setState({ isOpen: !isOpen }); + }; + + onColorChangeClick = () => { + // const { dispatch, setName } = this.props; + // dispatch({ + // type: "color by gene set", + // colorAccessor: setName, + // }); + }; + + toggleSummaryHisto = () => { + const { toggleSummaryHisto } = this.state; + this.setState({ toggleSummaryHisto: !toggleSummaryHisto }); + }; + + render() { + const { setName, setGenes, isDiffexp } = this.props; + const { isOpen, toggleSummaryHisto } = this.state; + const genesetNameLengthVisible = 120; /* this magic number determines how much of a long geneset name we see */ + return ( +
+
+ {} + } + style={{ + cursor: "pointer", + userSelect: "none", + }} + onClick={this.onGenesetMenuClick} + > + + + {setName} + + + {isOpen ? ( + + ) : ( + + )} + +
+ +
+
+ +
+ {isOpen ? ( + + + + ) : null} +
+ + {isOpen && !toggleSummaryHisto + ? _.map(setGenes, (gene) => { + return ( + + ); + }) + : null} +
+ ); + } +} + +export default GeneSet; diff --git a/client/src/components/geneExpression/index.js b/client/src/components/geneExpression/index.js index b7a9bf21..43d2fc79 100644 --- a/client/src/components/geneExpression/index.js +++ b/client/src/components/geneExpression/index.js @@ -1,59 +1,78 @@ -/* rc slider https://www.npmjs.com/package/rc-slider */ - import React from "react"; -import _ from "lodash"; import { connect } from "react-redux"; -import HistogramBrush from "../brushableHistogram"; -import * as globals from "../../globals"; -import AddGenes from "./addGenes"; +import { Button } from "@blueprintjs/core"; +import GeneSet from "./geneSet"; + +import CreateGenesetDialogue from "./menus/createGenesetDialogue"; +import EditGenesetNameDialogue from "./menus/editGenesetNameDialogue"; @connect((state) => { return { - userDefinedGenes: state.controls.userDefinedGenes, differential: state.differential, + genesets: state.genesets.genesets, }; }) class GeneExpression extends React.Component { + renderGeneSets = () => { + const sets = []; + const { genesets } = this.props; + + for (const [name, genes] of genesets) { + sets.push( + + ); + } + return sets; + }; + + renderDiffexpGeneSets = () => { + const { differential } = this.props; + + const setGenes = []; + + if (differential.diffExp) { + differential.diffExp.forEach((diffexpGene) => { + setGenes.push(diffexpGene[0]); + }); + } + + return differential.diffExp ? ( + + ) : null; + }; + + handleActivateCreateGenesetMode = () => { + const { dispatch } = this.props; + dispatch({ type: "geneset: activate add new geneset mode" }); + }; + render() { - const { userDefinedGenes, differential } = this.props; return ( -
+
- - {userDefinedGenes.length > 0 - ? _.map(userDefinedGenes, (geneName, index) => { - return ( - - ); - }) - : null} -
-
- {differential.diffExp - ? _.map(differential.diffExp, (value, index) => { - return ( - - ); - }) - : null} +
+ +
+ +
+
{this.renderDiffexpGeneSets()}
+
{this.renderGeneSets()}
); } diff --git a/client/src/components/geneExpression/menus/addGeneToGenesetDialogue.js b/client/src/components/geneExpression/menus/addGeneToGenesetDialogue.js new file mode 100644 index 00000000..f8148e19 --- /dev/null +++ b/client/src/components/geneExpression/menus/addGeneToGenesetDialogue.js @@ -0,0 +1,92 @@ +import React from "react"; +import { connect } from "react-redux"; +import AnnoDialog from "../../annoDialog"; +import LabelInput from "../../labelInput"; +import parseBulkGeneString from "../../../util/parseBulkGeneString"; + +@connect((state) => ({ + genesetsUI: state.genesetsUI, +})) +class AddGeneToGenesetDialogue extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + genesToAdd: "", + }; + } + + disableAddGeneMode = () => { + const { dispatch } = this.props; + dispatch({ + type: "geneset: disable add new genes mode", + }); + }; + + handleAddGeneToGeneSet = (e) => { + const { geneset, dispatch } = this.props; + const { genesToAdd } = this.state; + + const genesTmpHardcodedFormat = []; + + const genesArrayFromString = parseBulkGeneString(genesToAdd); + + genesArrayFromString.forEach((_gene) => { + genesTmpHardcodedFormat.push({ + geneSymbol: _gene, + }); + }); + + dispatch({ + type: "geneset: add genes", + genesetName: geneset, + genes: genesTmpHardcodedFormat, + }); + dispatch({ + type: "geneset: disable add new genes mode", + }); + if (e) e.preventDefault(); + }; + + handleChange = (e) => { + this.setState({ genesToAdd: e }); + }; + + render() { + const { geneset, genesetsUI } = this.props; + const { genesToAdd } = this.state; + + return ( + <> + + } + handleSubmit={this.handleAddGeneToGeneSet} + handleCancel={this.disableAddGeneMode} + /> + + ); + } +} + +export default AddGeneToGenesetDialogue; diff --git a/client/src/components/geneExpression/addGenes.js b/client/src/components/geneExpression/menus/addGenes.js similarity index 96% rename from client/src/components/geneExpression/addGenes.js rename to client/src/components/geneExpression/menus/addGenes.js index 2d1ca1b4..f9bdda99 100644 --- a/client/src/components/geneExpression/addGenes.js +++ b/client/src/components/geneExpression/menus/addGenes.js @@ -1,7 +1,4 @@ -/* rc slider https://www.npmjs.com/package/rc-slider */ - import React from "react"; -import _ from "lodash"; import fuzzysort from "fuzzysort"; import { connect } from "react-redux"; import { Suggest } from "@blueprintjs/select"; @@ -13,14 +10,15 @@ import { Intent, MenuItem, } from "@blueprintjs/core"; -import * as globals from "../../globals"; -import actions from "../../actions"; +import * as globals from "../../../globals"; +import actions from "../../../actions"; import { postUserErrorToast, keepAroundErrorToast, -} from "../framework/toasters"; +} from "../../framework/toasters"; -import { memoize } from "../../util/dataframe/util"; +import { memoize } from "../../../util/dataframe/util"; +import parseBulkGeneString from "../../../util/parseBulkGeneString"; const renderGene = (fuzzySortResult, { handleClick, modifiers }) => { if (!modifiers.matchesPredicate) { @@ -101,7 +99,7 @@ class AddGenes extends React.Component { Apod,,, Cd74,, ,,, Foo, Bar-2,, */ if (bulkAdd !== "") { - const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), ""); + const genes = parseBulkGeneString(bulkAdd); if (genes.length === 0) { return keepAroundErrorToast("Must enter a gene name."); } diff --git a/client/src/components/geneExpression/menus/createGenesetDialogue.js b/client/src/components/geneExpression/menus/createGenesetDialogue.js new file mode 100644 index 00000000..7fc4bd55 --- /dev/null +++ b/client/src/components/geneExpression/menus/createGenesetDialogue.js @@ -0,0 +1,149 @@ +import _ from "lodash"; +import React from "react"; +import { connect } from "react-redux"; +import AnnoDialog from "../../annoDialog"; +import LabelInput from "../../labelInput"; + +@connect((state) => ({ + annotations: state.annotations, + schema: state.annoMatrix?.schema, + ontology: state.ontology, + obsCrossfilter: state.obsCrossfilter, + genesets: state.genesets.genesets, + genesetsUI: state.genesetsUI, +})) +class CreateGenesetDialogue extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + genesetName: "", + genesToPopulateGeneset: "", + }; + } + + disableCreateGenesetMode = (e) => { + const { dispatch } = this.props; + this.setState({ + genesetName: "", + genesToPopulateGeneset: "", + }); + dispatch({ + type: "geneset: disable create geneset mode", + }); + if (e) e.preventDefault(); + }; + + createGeneset = (e) => { + const { dispatch } = this.props; + const { genesetName, genesToPopulateGeneset } = this.state; + + dispatch({ + type: "geneset: create", + genesetName, + genesetDescription: "", + }); + if (genesToPopulateGeneset) { + const genesTmpHardcodedFormat = []; + + const genesArrayFromString = _.pull( + _.uniq(genesToPopulateGeneset.split(/[ ,]+/)), + "" + ); + + genesArrayFromString.forEach((_gene) => { + genesTmpHardcodedFormat.push({ + geneSymbol: _gene, + }); + }); + + dispatch({ + type: "geneset: add genes", + genesetName, + genes: genesTmpHardcodedFormat, + }); + } + dispatch({ + type: "geneset: disable create geneset mode", + }); + this.setState({ + genesetName: "", + genesToPopulateGeneset: "", + }); + e.preventDefault(); + }; + + genesetNameError = () => { + return false; + }; + + handleChange = (e) => { + this.setState({ genesetName: e }); + }; + + handleGenesetInputChange = (e) => { + this.setState({ genesToPopulateGeneset: e }); + }; + + instruction = (genesetName, genesets) => { + return genesets.has(genesetName) + ? "Geneset name must be unique." + : "New, unique geneset name"; + }; + + validate = (genesetName, genesets) => { + return genesets.has(genesetName); + }; + + render() { + const { genesetName } = this.state; + const { metadataField, genesetsUI, genesets } = this.props; + + return ( + <> + + } + secondaryInstructions="Optionally add a list of comma separated genes to populate the gene set" + secondaryInput={ + + } + handleSubmit={this.createGeneset} + handleCancel={this.disableCreateGenesetMode} + /> + + ); + } +} + +export default CreateGenesetDialogue; diff --git a/client/src/components/geneExpression/menus/editGenesetNameDialogue.js b/client/src/components/geneExpression/menus/editGenesetNameDialogue.js new file mode 100644 index 00000000..0ac6e724 --- /dev/null +++ b/client/src/components/geneExpression/menus/editGenesetNameDialogue.js @@ -0,0 +1,95 @@ +import React from "react"; +import { connect } from "react-redux"; +import AnnoDialog from "../../annoDialog"; +import LabelInput from "../../labelInput"; + +@connect((state) => ({ + annotations: state.annotations, + schema: state.annoMatrix?.schema, + ontology: state.ontology, + obsCrossfilter: state.obsCrossfilter, + genesetsUI: state.genesetsUI, +})) +class RenameGeneset extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + genesetName: "", + }; + } + + disableEditGenesetNameMode = (e) => { + const { dispatch } = this.props; + this.setState({ + genesetName: "", + }); + dispatch({ + type: "geneset: disable rename geneset mode", + }); + if (e) e.preventDefault(); + }; + + renameGeneset = (e) => { + const { dispatch, genesetsUI } = this.props; + const { genesetName } = this.state; + + dispatch({ + type: "geneset: update", + genesetName: genesetsUI.isEditingGenesetName, + update: { genesetName }, + }); + dispatch({ + type: "geneset: disable rename geneset mode", + }); + e.preventDefault(); + }; + + genesetNameError = () => { + return false; + }; + + handleChange = (e) => { + this.setState({ genesetName: e }); + }; + + render() { + const { genesetName } = this.state; + const { genesetsUI } = this.props; + + return ( + <> + + } + handleSubmit={this.renameGeneset} + handleCancel={this.disableEditGenesetNameMode} + /> + + ); + } +} + +export default RenameGeneset; diff --git a/client/src/components/geneExpression/menus/genesetMenus.js b/client/src/components/geneExpression/menus/genesetMenus.js new file mode 100644 index 00000000..ccdfe9c5 --- /dev/null +++ b/client/src/components/geneExpression/menus/genesetMenus.js @@ -0,0 +1,120 @@ +import React from "react"; +import { connect } from "react-redux"; +import { + Button, + Menu, + MenuItem, + Popover, + Position, + Tooltip, + Icon, + PopoverInteractionKind, +} from "@blueprintjs/core"; + +import * as globals from "../../../globals"; +import AddGeneToGenesetDialogue from "./addGeneToGenesetDialogue"; + +@connect((state) => { + return { + genesetsUI: state.genesetsUI, + }; +}) +class GenesetMenus extends React.PureComponent { + constructor(props) { + super(props); + this.state = {}; + } + + activateAddGeneToGenesetMode = () => { + const { dispatch, geneset } = this.props; + dispatch({ + type: "geneset: activate add new genes mode", + geneset, + }); + }; + + activateEditGenesetNameMode = () => { + const { dispatch, geneset } = this.props; + + dispatch({ + type: "geneset: activate rename geneset mode", + data: geneset, + }); + }; + + handleDeleteCategory = () => { + const { dispatch, geneset } = this.props; + dispatch({ type: "geneset: delete", genesetName: geneset }); + }; + + render() { + const { geneset, genesetsEditable, createText } = this.props; + + return ( + <> + {genesetsEditable ? ( + <> + +
{scatterplotXXaccessor && scatterplotYYaccessor ? ( diff --git a/client/src/components/leftSidebar/infoMenu.js b/client/src/components/leftSidebar/infoMenu.js index 6ab9bdde..f709af2c 100644 --- a/client/src/components/leftSidebar/infoMenu.js +++ b/client/src/components/leftSidebar/infoMenu.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 import React from "react"; import { Button, Menu, MenuItem, Popover, Position } from "@blueprintjs/core"; import { IconNames } from "@blueprintjs/icons"; diff --git a/client/src/components/menubar/cellSetButtons.js b/client/src/components/menubar/cellSetButtons.js index ae4bb95c..f421d5ec 100644 --- a/client/src/components/menubar/cellSetButtons.js +++ b/client/src/components/menubar/cellSetButtons.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 import React from "react"; import { AnchorButton, Tooltip } from "@blueprintjs/core"; import { connect } from "react-redux"; diff --git a/client/src/components/rightSidebar/index.js b/client/src/components/rightSidebar/index.js index 4e75ba05..43802b99 100644 --- a/client/src/components/rightSidebar/index.js +++ b/client/src/components/rightSidebar/index.js @@ -1,7 +1,5 @@ -// jshint esversion: 6 import React from "react"; import { connect } from "react-redux"; -import Continuous from "../continuous/continuous"; import GeneExpression from "../geneExpression"; import * as globals from "../../globals"; @@ -22,10 +20,10 @@ class RightSidebar extends React.Component { overflowY: "inherit", height: "inherit", width: "inherit", + padding: globals.leftSidebarSectionPadding, }} > - ); } diff --git a/client/src/components/scatterplot/util.js b/client/src/components/scatterplot/util.js index f60216f0..54393dde 100644 --- a/client/src/components/scatterplot/util.js +++ b/client/src/components/scatterplot/util.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 export const margin = { top: 40, right: 5, bottom: 20, left: 60 }; export const width = 340; export const height = 340 - margin.top - margin.bottom; diff --git a/client/src/components/util/truncate.js b/client/src/components/util/truncate.js index 33e64dcc..80c9eaf0 100644 --- a/client/src/components/util/truncate.js +++ b/client/src/components/util/truncate.js @@ -1,5 +1,5 @@ import React, { cloneElement } from "react"; -import { Tooltip, Position } from "@blueprintjs/core"; +import { Tooltip2 } from "@blueprintjs/popover2"; import { tooltipHoverOpenDelayQuick } from "../../globals"; @@ -86,18 +86,12 @@ export default (props) => { }) ); return ( - {newChildren} - + ); }; diff --git a/client/src/index.css b/client/src/index.css index 185bb657..80218dc7 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -7,6 +7,7 @@ namespace mangling on these definitions. See webpack config for specifics. @import "~@blueprintjs/core/lib/css/blueprint.css"; @import "~@blueprintjs/icons/lib/css/blueprint-icons.css"; @import "~@blueprintjs/select/lib/css/blueprint-select.css"; +@import "~@blueprintjs/popover2/lib/css/blueprint-popover2.css"; @font-face { font-family: "Roboto Condensed"; diff --git a/client/src/reducers/autosave.js b/client/src/reducers/autosave.js index e545708e..5b03cab6 100644 --- a/client/src/reducers/autosave.js +++ b/client/src/reducers/autosave.js @@ -53,7 +53,7 @@ const Autosave = ( return { ...state, genesetSaveInProgress: false, - lastSavedGenesets: nextSharedState.genesets, + lastSavedGenesets: nextSharedState.genesets.genesets, }; } diff --git a/client/src/reducers/config.js b/client/src/reducers/config.js index e6bfad4d..22858c8f 100644 --- a/client/src/reducers/config.js +++ b/client/src/reducers/config.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 const Config = ( state = { displayNames: null, diff --git a/client/src/reducers/controls.js b/client/src/reducers/controls.js index d5c4d406..c2e195b1 100644 --- a/client/src/reducers/controls.js +++ b/client/src/reducers/controls.js @@ -1,5 +1,3 @@ -// jshint esversion: 6 - import _ from "lodash"; import { subsetAndResetGeneLists } from "../util/stateManager/controlsHelpers"; diff --git a/client/src/reducers/differential.js b/client/src/reducers/differential.js index 8ef7e2b6..74bbce67 100644 --- a/client/src/reducers/differential.js +++ b/client/src/reducers/differential.js @@ -1,5 +1,3 @@ -// jshint esversion: 6 - const Differential = ( state = { diffExp: null, diff --git a/client/src/reducers/genesets.js b/client/src/reducers/genesets.js index 56458330..2e2dff42 100644 --- a/client/src/reducers/genesets.js +++ b/client/src/reducers/genesets.js @@ -200,6 +200,7 @@ const GeneSets = ( */ case "geneset: add genes": { const { genesetName, genes } = action; + if (!state.genesets.has(genesetName)) throw new Error("geneset: add genes -- geneset name does not exist."); diff --git a/client/src/reducers/genesetsUI.js b/client/src/reducers/genesetsUI.js new file mode 100644 index 00000000..a49aa9b9 --- /dev/null +++ b/client/src/reducers/genesetsUI.js @@ -0,0 +1,88 @@ +/* +Reducers for geneset UI-state. +*/ +const GeneSetsUI = ( + state = { + createGenesetModeActive: false, + isEditingGenesetName: false, + isAddingGenesToGeneset: false, + }, + action +) => { + switch (action.type) { + /** + * Activate interface for adding a new geneset + * No params, if the action is fired we flip + * a boolean here. + */ + case "geneset: activate add new geneset mode": { + return { + ...state, + createGenesetModeActive: true, + }; + } + /** + * Disable interface for adding a new geneset + * No params, if the action is fired we flip + * a boolean here. + */ + case "geneset: disable create geneset mode": { + return { + ...state, + createGenesetModeActive: false, + }; + } + /** + * Activate the interface for adding new genes to a geneset + * isAddingGenesToGeneset { + * geneset: string, name of geneset + * }, + */ + case "geneset: activate add new genes mode": { + return { + ...state, + isAddingGenesToGeneset: action.geneset, + }; + } + /** + * Disable the interface for adding new genes to a geneset + * No params, if the action is fired we flip + * a boolean here. + */ + case "geneset: disable add new genes mode": { + return { + ...state, + isAddingGenesToGeneset: false, + }; + } + /** + * Activate the interface for renaming a geneset + * isEditingGenesetName: { + * type: "geneset: activate rename geneset mode", + * data: geneset, // a string, name of geneset + * } + */ + case "geneset: activate rename geneset mode": { + return { + ...state, + isEditingGenesetName: action.data, + }; + } + /** + * Disable the interface for renaming a geneset + * No params, if the action is fired we flip + * a boolean here. + */ + case "geneset: disable rename geneset mode": { + return { + ...state, + isEditingGenesetName: false, + }; + } + + default: + return state; + } +}; + +export default GeneSetsUI; diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index e1def811..46812339 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -16,6 +16,7 @@ import layoutChoice from "./layoutChoice"; import controls from "./controls"; import annotations from "./annotations"; import genesets from "./genesets"; +import genesetsUI from "./genesetsUI"; import autosave from "./autosave"; import ontology from "./ontology"; import centroidLabels from "./centroidLabels"; @@ -33,6 +34,7 @@ const Reducer = undoable( ["ontology", ontology], ["annotations", annotations], ["genesets", genesets], + ["genesetsUI", genesetsUI], ["layoutChoice", layoutChoice], ["categoricalSelection", categoricalSelection], ["continuousSelection", continuousSelection], @@ -58,6 +60,7 @@ const Reducer = undoable( "layoutChoice", "centroidLabels", "genesets", + "genesetsUI", "annotations", ], undoableConfig diff --git a/client/src/reducers/undoableConfig.js b/client/src/reducers/undoableConfig.js index 1f298eb1..d733bdf4 100644 --- a/client/src/reducers/undoableConfig.js +++ b/client/src/reducers/undoableConfig.js @@ -17,6 +17,8 @@ const skipOnActions = new Set([ "window resize", "reset colorscale", "reset centroid labels", + "geneset: initial load", + "geneset: set tid", "graph brush change", "continuous metadata histogram brush", @@ -30,6 +32,9 @@ const skipOnActions = new Set([ "writable obs annotations - save complete", "writable obs annotations - save started", "writable obs annotations - save error", + "autosave: genesets started", + "autosave: genesets error", + "autosave: genesets complete", /* annotation component action */ "annotation: activate add new label mode", @@ -39,6 +44,14 @@ const skipOnActions = new Set([ "annotation: activate edit label mode", "annotation: cancel edit label mode", "set annotations collection name", + + /* geneset component action */ + "geneset: activate add new geneset mode", + "geneset: disable create geneset mode", + "geneset: activate add new genes mode", + "geneset: disable add new genes mode", + "geneset: activate rename geneset mode", + "geneset: disable rename geneset mode", ]); /* @@ -90,6 +103,14 @@ const saveOnActions = new Set([ "annotation: label current cell selection", "annotation: delete label", "annotation: category edited", + + /* geneset component action */ + "geneset: create", + "geneset: delete", + "geneset: update", + "geneset: add genes", + "geneset: delete genes", + "geneset: set gene description", ]); /** diff --git a/client/src/util/clamp.js b/client/src/util/clamp.js new file mode 100644 index 00000000..b1ae5f1d --- /dev/null +++ b/client/src/util/clamp.js @@ -0,0 +1,13 @@ +/** + * The function will ensure a number is above or below given thresholds + * @param val - a number + * @param rng - an array of two numbers, a min and a max + * Ie., in the case of a histogram brush selection: + * const x0 = x(clamp(selectionRange[0], [min, max])); + * const x1 = x(clamp(selectionRange[1], [min, max])); + * @returns a number + */ + +export default function clamp(val, rng) { + return Math.max(Math.min(val, rng[1]), rng[0]); +} diff --git a/client/src/util/maybeScientific.js b/client/src/util/maybeScientific.js new file mode 100644 index 00000000..234f49e1 --- /dev/null +++ b/client/src/util/maybeScientific.js @@ -0,0 +1,27 @@ +import significantDigits from "./significantDigits"; + +/** + * The function will conditionally convert a d3 axis label to scientific notation + * @param x - a d3 axis, ie: + * d3 + * .axisBottom(x) + * .ticks(4) + * .tickFormat(d3.format(maybeScientific(x))) + * @returns - the number formatted as scientific, if it's big enough + */ + +export default function maybeScientific(x) { + let format = ","; + const _ticks = x.ticks(4); + + if (x.domain().some((n) => Math.abs(n) >= 10000)) { + /* + heuristic: if the last tick d3 wants to render has one significant + digit ie., 2000, render 2e+3, but if it's anything else ie., 42000000 render + 4.20e+n + */ + format = significantDigits(_ticks[_ticks.length - 1]) === 1 ? ".0e" : ".2e"; + } + + return format; +} diff --git a/client/src/util/parseBulkGeneString.js b/client/src/util/parseBulkGeneString.js new file mode 100644 index 00000000..c454857f --- /dev/null +++ b/client/src/util/parseBulkGeneString.js @@ -0,0 +1,10 @@ +/** + * The function will split string by comma or space, return unique non-empty strings + * @param geneString - a string of comma delimited genes + * @returns an array + */ +import _ from "lodash"; + +export default function parseBulkGeneString(geneString) { + return _.pull(_.uniq(geneString.split(/[ ,]+/)), ""); +} diff --git a/client/src/util/parseRGB.js b/client/src/util/parseRGB.js index 91ba5c1f..d8546d87 100644 --- a/client/src/util/parseRGB.js +++ b/client/src/util/parseRGB.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 import scaleRGB from "./scaleRGB"; // maintain a cache of already parsed RGB names, as it is reasonably expensive diff --git a/client/src/util/scaleLinear.js b/client/src/util/scaleLinear.js index f64ab123..239a5dc0 100644 --- a/client/src/util/scaleLinear.js +++ b/client/src/util/scaleLinear.js @@ -1,5 +1,3 @@ -// jshint esversion: 6 - // Substitute for a d3 linear scale - less flexible, more performant. // Returns a function which will scale a value. // diff --git a/client/src/util/scaleRGB.js b/client/src/util/scaleRGB.js index d49f7414..10b42d35 100644 --- a/client/src/util/scaleRGB.js +++ b/client/src/util/scaleRGB.js @@ -1,4 +1,3 @@ -// jshint esversion: 6 export default (input) => { const outputMax = 1; const outputMin = 0; diff --git a/client/src/util/typedCrossfilter/positiveIntervals.js b/client/src/util/typedCrossfilter/positiveIntervals.js index fa89e70c..8fe7e54f 100644 --- a/client/src/util/typedCrossfilter/positiveIntervals.js +++ b/client/src/util/typedCrossfilter/positiveIntervals.js @@ -1,5 +1,3 @@ -// jshint esversion: 6 - // Interval operations - very simple version of interval set relationship // operators. An interval is a multi-interval list of [min, max), // where min and max are mandatory. Constraints: diff --git a/client/src/util/typedCrossfilter/util.js b/client/src/util/typedCrossfilter/util.js index 4bd62940..8d28d65a 100644 --- a/client/src/util/typedCrossfilter/util.js +++ b/client/src/util/typedCrossfilter/util.js @@ -1,5 +1,3 @@ -// jshint esversion: 6 - import { sortIndex } from "./sort"; import { rangeFill as fillRange } from "../range"; diff --git a/package.json b/package.json deleted file mode 100644 index e3722f75..00000000 --- a/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "cellxgene", - "scripts": { - "postinstall": "npm install --prefix client && npm run build --prefix client && make copy-client-assets" - } -}