From 3660a6cc27d3f4d08d5df3d714d4003b28dab90c Mon Sep 17 00:00:00 2001 From: Bruce Martin Date: Wed, 18 Sep 2019 04:33:41 -0700 Subject: [PATCH] Experimental - manual annotations (#837) * icons, partway * redux for values * onChange * cancel * annotations lifecycle for category names * copy categorical * edit category * add Dataframe.withColsFrom * render user annotations; default add/delete annotation category * add label name to actions * category name edit * error checking improvements * change schema field isUserAnnotation to writable * always have an unassigned label; implement delete label * implement add new label and edit label name * label current cell selection * fix select exact bug in crossfilter * clean up categorical reducer * fix tests * remove debugging printf * implement subset/reset for user annotations * undo redo support for user annotations * remove duplicate button from categories * add modal * remove obsolete duplicate annotation reducers * remove old debugging printf * connect modal to annotation create and dup * initial full-stack wiring * finish up end-to-end wiring * fix existing unit tests * fix pytests to match new schema API * remove debugging printfs * add label file rotation * remove obsolete comment * add fbs encode/decode tests * add tests for writable annotations * simplify code * fix hashing bug with FBS encoding * lint * fix smoke tests * improve error checking in Dataframe.withColsFrom * add unit test for Dataframe.withColsFrom * add unit test for Dataframe.columns and Dataframe.renameCol * fix bug in FBS encode, add better error checks, refactor * add FBS encode/decode test * add clarifying comment * clean up action type names; fix state inconsistency in crossfilter update * change autosave timer to 2.5sec * sort categorical metadata render order so it remains consistent * add temporary autogenerated label for add-new-label operation * fix hover-over label menu interference with cell highlighting * remove debugging code * add missing reducer cases & fix typo * make dataframe memoize more general purpose * add dev mode for annos * fix error on select duplicate * handle zero occupancy categories * correctly maintain unclipped AND clipped world * correctly handle zero length FBS matrix and label files * ensure all writable categorical schema contains an unassigned category * handle case where building occupancy stack for category with no members * dialog for creating label, disable button if duplicate or empty * visually separate writeable * edit category * fix edit category name * remove debugging code * fix edit annotation label * visually define unassigned, change options * Pull in requirements.txt from `master` * label currently selected cells * duplicate label * lint * fix pytest merge issues * rename --label-file to --experimental-label-file * remove debugging console log * spelling error fix; fix bug found in PR review. * lint --- client/__tests__/e2e/data.js | 8 +- .../util/dataframe/dataframe.test.js | 79 +++++ .../__tests__/util/stateManager/fbs.test.js | 34 ++ .../util/stateManager/sampleResponses.js | 8 + .../__tests__/util/stateManager/world.test.js | 20 +- .../util/typedCrossfilter/sort.test.js | 2 + client/package.json | 1 + client/src/actions/index.js | 50 ++- client/src/components/app.js | 2 + client/src/components/autosave/index.js | 81 +++++ .../src/components/categorical/categorical.js | 144 ++++++++- client/src/components/categorical/category.js | 261 ++++++++++++++-- .../src/components/categorical/occupancy.js | 51 +-- client/src/components/categorical/value.js | 295 +++++++++++++++--- .../src/components/framework/custom-icons.js | 16 + client/src/globals.js | 3 + client/src/reducers/annotations.js | 88 ++++++ client/src/reducers/autosave.js | 53 ++++ client/src/reducers/categoricalSelection.js | 46 ++- client/src/reducers/colors.js | 21 ++ client/src/reducers/controls.js | 2 +- client/src/reducers/crossfilter.js | 78 ++++- client/src/reducers/index.js | 62 ++-- client/src/reducers/resetCache.js | 11 +- client/src/reducers/undoableConfig.js | 10 +- client/src/reducers/universe.js | 189 ++++++++++- client/src/reducers/world.js | 155 ++++++++- client/src/util/dataframe/dataframe.js | 89 ++++++ client/src/util/dataframe/labelIndex.js | 17 + client/src/util/dataframe/util.js | 14 +- .../util/stateManager/annotationsHelpers.js | 168 ++++++++++ client/src/util/stateManager/colorHelpers.js | 5 +- .../src/util/stateManager/controlsHelpers.js | 116 ++++--- client/src/util/stateManager/index.js | 3 + client/src/util/stateManager/matrix.js | 107 ++++++- client/src/util/stateManager/schemaHelpers.js | 96 ++++++ client/src/util/stateManager/universe.js | 48 +-- client/src/util/stateManager/world.js | 15 +- .../src/util/typedCrossfilter/crossfilter.js | 32 +- client/src/util/typedCrossfilter/sort.js | 13 + server/app/driver/driver.py | 14 + server/app/rest_api/rest.py | 21 +- server/app/scanpy_engine/labels.py | 49 +++ server/app/scanpy_engine/scanpy_engine.py | 132 ++++++-- server/app/util/errors.py | 9 + server/app/util/fbs/matrix.py | 132 ++++++-- server/cli/launch.py | 22 +- server/test/schema.json | 21 +- server/test/test_fbs.py | 94 ++++++ server/test/test_scanpy_engine.py | 172 +++++++++- server/test/test_scanpy_engine_data_load.py | 1 + 51 files changed, 2823 insertions(+), 337 deletions(-) create mode 100644 client/__tests__/util/stateManager/fbs.test.js create mode 100644 client/src/components/autosave/index.js create mode 100644 client/src/components/framework/custom-icons.js create mode 100644 client/src/reducers/annotations.js create mode 100644 client/src/reducers/autosave.js create mode 100644 client/src/util/stateManager/annotationsHelpers.js create mode 100644 client/src/util/stateManager/schemaHelpers.js create mode 100644 server/app/scanpy_engine/labels.py create mode 100644 server/test/test_fbs.py diff --git a/client/__tests__/e2e/data.js b/client/__tests__/e2e/data.js index 9a5ab836..2d8ebeaf 100644 --- a/client/__tests__/e2e/data.js +++ b/client/__tests__/e2e/data.js @@ -87,7 +87,13 @@ export const datasets = { categorical: { louvain: { "B cells": "342", - Megakaryocytes: "15" + "CD14+ Monocytes": "0", + "CD4 T cells": "0", + "CD8 T cells": "0", + "Dendritic cells": "0", + "FCGR3A+ Monocytes": "0", + Megakaryocytes: "15", + "NK cells": "0" } }, lasso: { diff --git a/client/__tests__/util/dataframe/dataframe.test.js b/client/__tests__/util/dataframe/dataframe.test.js index a89b3486..7bf54114 100644 --- a/client/__tests__/util/dataframe/dataframe.test.js +++ b/client/__tests__/util/dataframe/dataframe.test.js @@ -452,6 +452,59 @@ describe("dataframe factories", () => { }); }); + describe("withColsFrom", () => { + test("error conditions", () => { + /* + make sure we catch common errors: + - duplicate column names + - dimensionality difference + */ + const dfA = new Dataframe.Dataframe( + [2, 3], + [["red", "blue"], [true, false], [1, 0]], + null, + new Dataframe.KeyIndex(["colors", "bools", "numbers"]) + ); + + /* different dimensionality should throw error */ + const dfB = new Dataframe.Dataframe( + [3, 1], + [["red", "blue", "green"]], + null, + new Dataframe.KeyIndex(["colorsA"]) + ); + expect(() => dfA.withColsFrom(dfB)).toThrow(RangeError); + + /* duplicate labels should throw an error */ + expect(() => dfA.withColsFrom(dfA)).toThrow(Error); + }); + + test("simple", () => { + /* simple test that it works as expected in common case */ + const dfA = new Dataframe.Dataframe( + [2, 1], + [["red", "blue"]], + null, + new Dataframe.KeyIndex(["colors"]) + ); + const dfB = new Dataframe.Dataframe( + [2, 1], + [[true, false]], + null, + new Dataframe.KeyIndex(["bools"]) + ); + + const dfC = dfA.withColsFrom(dfB); + expect(dfC).toBeDefined(); + expect(dfC.dims).toEqual([2, 2]); + expect(dfC.colIndex.keys()).toEqual(["colors", "bools"]); + expect(dfC.rowIndex).toEqual(dfA.rowIndex); + expect(dfC.rowIndex.keys()).toEqual(dfA.rowIndex.keys()); + expect(dfC.col("colors").asArray()).toEqual(["red", "blue"]); + expect(dfC.col("bools").asArray()).toEqual([true, false]); + }); + }); + describe("dropCol", () => { test("KeyIndex", () => { const df = new Dataframe.Dataframe( @@ -567,6 +620,32 @@ describe("dataframe factories", () => { expect(dfB.iat(0, 1)).toEqual(1); expect(dfB.iat(0, 2)).toEqual(1); }); + + test("columns", () => { + const df = Dataframe.Dataframe.create( + [3, 3], + [new Array(3).fill(0), new Array(3).fill(0), new Array(3).fill(0)] + ); + + expect(df).toBeDefined(); + expect(df.columns()).toHaveLength(3); + expect(df.columns()[0]).toEqual(df.icol(0)); + expect(df.columns()[2]).toEqual(df.icol(2)); + }); + + test("renameCol", () => { + const dfA = new Dataframe.Dataframe( + [2, 2], + [[true, false], [1, 0]], + null, + new Dataframe.KeyIndex(["A", "B"]) + ); + const dfB = dfA.renameCol("B", "C"); + expect(dfA.colIndex.keys()).toEqual(["A", "B"]); + expect(dfB.colIndex.keys()).toEqual(["A", "C"]); + expect(dfA.dims).toMatchObject(dfB.dims); + expect(dfA.columns()).toMatchObject(dfB.columns()); + }); }); }); diff --git a/client/__tests__/util/stateManager/fbs.test.js b/client/__tests__/util/stateManager/fbs.test.js new file mode 100644 index 00000000..612a2316 --- /dev/null +++ b/client/__tests__/util/stateManager/fbs.test.js @@ -0,0 +1,34 @@ +/* +test FBS encode/decode API +*/ +import { Dataframe, KeyIndex } from "../../../src/util/dataframe"; +import { + decodeMatrixFBS, + encodeMatrixFBS +} from "../../../src/util/stateManager/matrix"; + +describe("encode/decode", () => { + test("round trip", () => { + const columns = [ + ["red", "green", "blue"], + new Int32Array(3).fill(0), + new Uint32Array(3).fill(1), + new Float32Array(3).fill(2) + ]; + + const dfNoColIdx = new Dataframe([3, 4], columns); + const dfA = decodeMatrixFBS(encodeMatrixFBS(dfNoColIdx)); + expect([dfA.nRows, dfA.nCols]).toEqual(dfNoColIdx.dims); + expect(dfA.colIdx).toBeNull(); + expect(dfA.rowIdx).toBeNull(); + expect(dfA.columns).toEqual(columns); + + const colIndex = new KeyIndex(["a", "b", "c", "d"]); + const dfWithColIdx = new Dataframe([3, 4], columns, null, colIndex); + const dfB = decodeMatrixFBS(encodeMatrixFBS(dfWithColIdx)); + expect([dfB.nRows, dfB.nCols]).toEqual(dfWithColIdx.dims); + expect(dfB.colIdx).toEqual(colIndex.keys()); + expect(dfB.rowIdx).toBeNull(); + expect(dfB.columns).toEqual(columns); + }); +}); diff --git a/client/__tests__/util/stateManager/sampleResponses.js b/client/__tests__/util/stateManager/sampleResponses.js index 2b66bac0..17fb0e8b 100644 --- a/client/__tests__/util/stateManager/sampleResponses.js +++ b/client/__tests__/util/stateManager/sampleResponses.js @@ -110,6 +110,14 @@ function encodeTypedArray(builder, uType, uData) { } function encodeMatrix(columns, colIndex = undefined) { + /* + IMPORTANT: this is not a general purpose encoder. in particular, + it doesn't correctly handle all column index types, nor does it + handle all column typedarray types. + + encodeMatrixFBS in matrix.py is more general. This is used only + as a testing santity check (alt implementation). + */ const utf8Encoder = new TextEncoder("utf-8"); const builder = new flatbuffers.Builder(1024); const cols = _.map(columns, carr => { diff --git a/client/__tests__/util/stateManager/world.test.js b/client/__tests__/util/stateManager/world.test.js index 6ba3424b..528fc9a9 100644 --- a/client/__tests__/util/stateManager/world.test.js +++ b/client/__tests__/util/stateManager/world.test.js @@ -18,11 +18,11 @@ const defaultBigBang = () => { /* create unverse, world, crossfilter and dimensionMap */ /* create universe */ const universe = Universe.createUniverseFromResponse( - REST.config, - REST.schema, - REST.annotationsObs, - REST.annotationsVar, - REST.layoutObs + _.cloneDeep(REST.config), + _.cloneDeep(REST.schema), + _.cloneDeep(REST.annotationsObs), + _.cloneDeep(REST.annotationsVar), + _.cloneDeep(REST.layoutObs) ); /* create world */ const world = World.createWorldFromEntireUniverse(universe); @@ -43,11 +43,11 @@ const defaultBigBang = () => { describe("createWorldFromEntireUniverse", () => { test("create from REST sample", () => { const universe = Universe.createUniverseFromResponse( - REST.config, - REST.schema, - REST.annotationsObs, - REST.annotationsVar, - REST.layoutObs + _.cloneDeep(REST.config), + _.cloneDeep(REST.schema), + _.cloneDeep(REST.annotationsObs), + _.cloneDeep(REST.annotationsVar), + _.cloneDeep(REST.layoutObs) ); expect(universe).toBeDefined(); diff --git a/client/__tests__/util/typedCrossfilter/sort.test.js b/client/__tests__/util/typedCrossfilter/sort.test.js index a1d1125d..2114c1b5 100644 --- a/client/__tests__/util/typedCrossfilter/sort.test.js +++ b/client/__tests__/util/typedCrossfilter/sort.test.js @@ -180,6 +180,7 @@ describe("lowerBound", () => { expect(lowerBound([0, 1, 2, 3], 1, 0, 4)).toEqual(1); expect(lowerBound([0, 1, 2, 3], 3, 0, 4)).toEqual(3); expect(lowerBound([0, 1, 2, 3], 4, 0, 4)).toEqual(4); + expect(lowerBound([0, 1, 2, 3], 4, 0, 3)).toEqual(3); expect(lowerBound([0, 1, 2, 3, 4], -1, 0, 5)).toEqual(0); expect(lowerBound([0, 1, 2, 3, 4], 0, 0, 5)).toEqual(0); @@ -204,6 +205,7 @@ describe("lowerBound", () => { expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1); expect(lowerBound(new Float32Array([0, 1, 2, 3]), 3, 0, 4)).toEqual(3); expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 4)).toEqual(4); + expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 3)).toEqual(3); expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), -1, 0, 5)).toEqual(0); expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 0, 0, 5)).toEqual(0); diff --git a/client/package.json b/client/package.json index a11a99e5..b4d6486e 100644 --- a/client/package.json +++ b/client/package.json @@ -6,6 +6,7 @@ "repository": "https://github.com/chanzuckerberg/cellxgene", "scripts": { "backend-dev": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch ", + "backend-dev-anno": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-label-file labels.csv ", "build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js", "clean": "rimraf build", "dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js", diff --git a/client/src/actions/index.js b/client/src/actions/index.js index d8c9c5ca..2751c106 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -1,7 +1,7 @@ // jshint esversion: 6 import _ from "lodash"; import * as globals from "../globals"; -import { Universe } from "../util/stateManager"; +import { Universe, MatrixFBS } from "../util/stateManager"; import { catchErrorsWrap, doJsonRequest, @@ -340,11 +340,57 @@ const resetInterface = () => (dispatch, getState) => { }); }; +const saveObsAnnotations = () => async (dispatch, getState) => { + const { universe } = getState(); + const { obsAnnotations, schema } = universe; + + dispatch({ + type: "writable obs annotations - save started" + }); + + const writableAnnotations = schema.annotations.obs.columns + .filter(s => s.writable) + .map(s => s.name); + const df = obsAnnotations.subset(null, writableAnnotations); + const matrix = MatrixFBS.encodeMatrixFBS(df); + try { + const res = await fetch( + `${globals.API.prefix}${globals.API.version}annotations/obs`, + { + method: "PUT", + body: matrix, + headers: new Headers({ + "Content-Type": "application/octet-stream" + }) + } + ); + if (res.ok) { + dispatch({ + type: "writable obs annotations - save complete", + obsAnnotations + }); + } else { + dispatch({ + type: "writable obs annotations - save error", + message: `HTTP error ${res.status} - ${res.statusText}`, + res + }); + } + } catch (error) { + dispatch({ + type: "writable obs annotations - save error", + message: error.toString(), + error + }); + } +}; + export default { regraph, resetInterface, requestSingleGeneExpressionCountsForColoringPOST, requestDifferentialExpression, requestUserDefinedGene, - doInitialDataLoad + doInitialDataLoad, + saveObsAnnotations }; diff --git a/client/src/components/app.js b/client/src/components/app.js index 8c2f1a11..a9c30877 100644 --- a/client/src/components/app.js +++ b/client/src/components/app.js @@ -8,6 +8,7 @@ import LeftSideBar from "./leftSidebar"; import Legend from "./continuousLegend"; import Graph from "./graph/graph"; import MenuBar from "./menubar"; +import Autosave from "./autosave"; import actions from "../actions"; @@ -89,6 +90,7 @@ class App extends React.Component { {loading ? null : } {loading ? null : } {loading ? null : } + {loading ? null : } diff --git a/client/src/components/autosave/index.js b/client/src/components/autosave/index.js new file mode 100644 index 00000000..78038ecc --- /dev/null +++ b/client/src/components/autosave/index.js @@ -0,0 +1,81 @@ +import React from "react"; +import { connect } from "react-redux"; + +import actions from "../../actions"; + +@connect(state => ({ + universe: state.universe, + obsAnnotations: state.universe.obsAnnotations, + saveInProgress: state.autosave?.saveInProgress ?? false, + lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations, + error: state.autosave?.error, + writableCategoriesEnabled: state.config?.parameters?.["label_file"] ?? false +})) +class Autosave extends React.Component { + constructor(props) { + super(props); + this.state = { + timer: null + }; + } + + componentDidMount() { + const { writableCategoriesEnabled } = this.props; + + let { timer } = this.state; + if (timer) clearInterval(timer); + if (writableCategoriesEnabled) { + timer = setInterval(this.tick, 2500); + } else { + timer = null; + } + this.setState({ timer }); + } + + componentWillUnmount() { + const { timer } = this.state; + if (timer) this.clearInterval(timer); + } + + tick = () => { + const { dispatch, saveInProgress } = this.props; + if (this.needToSave() && !saveInProgress) { + dispatch(actions.saveObsAnnotations()); + } + }; + + needToSave = () => { + /* return true if we need to save, false if we don't */ + const { obsAnnotations, lastSavedObsAnnotations } = this.props; + return ( + lastSavedObsAnnotations && obsAnnotations !== lastSavedObsAnnotations + ); + }; + + statusMessage() { + const { error } = this.props; + if (error) { + return `Autosave error: ${error}`; + } + return this.needToSave() ? "Unsaved" : "All saved"; + } + + render() { + const { writableCategoriesEnabled } = this.props; + return writableCategoriesEnabled ? ( +
+ {this.statusMessage()} +
+ ) : null; + } +} + +export default Autosave; diff --git a/client/src/components/categorical/categorical.js b/client/src/components/categorical/categorical.js index 437cf037..49512bf4 100644 --- a/client/src/components/categorical/categorical.js +++ b/client/src/components/categorical/categorical.js @@ -1,27 +1,161 @@ // jshint esversion: 6 import React from "react"; import _ from "lodash"; +import { + Button, + Tooltip, + InputGroup, + Dialog, + Classes, + MenuItem +} from "@blueprintjs/core"; +import { Select } from "@blueprintjs/select"; import { connect } from "react-redux"; import * as globals from "../../globals"; import Category from "./category"; @connect(state => ({ - categoricalSelection: state.categoricalSelection + categoricalSelection: state.categoricalSelection, + writableCategoriesEnabled: state.config?.parameters?.["label_file"] ?? false, + schema: state.world?.schema })) class Categories extends React.Component { + constructor(props) { + super(props); + this.state = { + createAnnoModeActive: false, + newCategoryText: "", + categoryToDuplicate: null + }; + } + + handleCreateUserAnno = () => { + const { dispatch } = this.props; + const { newCategoryText, categoryToDuplicate } = this.state; + dispatch({ + type: "annotation: create category", + data: newCategoryText, + categoryToDuplicate + }); + this.setState({ + createAnnoModeActive: false, + categoryToDuplicate: null, + newCategoryText: "" + }); + }; + + handleEnableAnnoMode = () => { + this.setState({ createAnnoModeActive: true }); + }; + + handleDisableAnnoMode = () => { + this.setState({ createAnnoModeActive: false }); + }; + + handleModalDuplicateCategorySelection = d => { + this.setState({ categoryToDuplicate: d }); + }; + render() { - const { categoricalSelection } = this.props; + const { createAnnoModeActive, categoryToDuplicate } = this.state; + const { + categoricalSelection, + writableCategoriesEnabled, + schema + } = this.props; if (!categoricalSelection) return null; + /* all names, sorted in display order. Will be rendered in this order */ + const allCategoryNames = Object.keys(categoricalSelection).sort(); + return (
- {_.map(categoricalSelection, (catState, catName) => ( - - ))} + {/* READ ONLY CATEGORICAL FIELDS */} + {/* this is duplicative but flat, could be abstracted */} + {_.map(allCategoryNames, catName => + !schema.annotations.obsByName[catName].writable ? ( + + ) : null + )} + {/* WRITEABLE FIELDS */} + {_.map(allCategoryNames, catName => + schema.annotations.obsByName[catName].writable ? ( + + ) : null + )} + {writableCategoriesEnabled ? ( +
+ +
+
+

New, unique category name:

+ + this.setState({ newCategoryText: e.target.value }) + } + leftIcon="tag" + /> +
+

+ Optionally duplicate all labels & cell assignments from + existing category into new category: +

+ +
+
+
+ + + + +
+
+
+ +
+ ) : null}
); } diff --git a/client/src/components/categorical/category.js b/client/src/components/categorical/category.js index f7fe4711..34cfbb37 100644 --- a/client/src/components/categorical/category.js +++ b/client/src/components/categorical/category.js @@ -2,7 +2,19 @@ import React from "react"; import _ from "lodash"; import { connect } from "react-redux"; import { FaChevronRight, FaChevronDown } from "react-icons/fa"; -import { Button, Tooltip } from "@blueprintjs/core"; +import { + Button, + Tooltip, + InputGroup, + Menu, + Dialog, + MenuItem, + Popover, + Classes, + Icon, + Position, + PopoverInteractionKind +} from "@blueprintjs/core"; import * as globals from "../../globals"; import Value from "./value"; @@ -10,14 +22,18 @@ import sortedCategoryValues from "./util"; @connect(state => ({ colorAccessor: state.colors.colorAccessor, - categoricalSelection: state.categoricalSelection + categoricalSelection: state.categoricalSelection, + annotations: state.annotations, + universe: state.universe })) class Category extends React.Component { constructor(props) { super(props); this.state = { isChecked: true, - isExpanded: false + isExpanded: false, + newCategoryText: "", + newLabelText: "" }; } @@ -51,6 +67,74 @@ class Category extends React.Component { } } + activateAddNewLabelMode = () => { + const { dispatch, metadataField } = this.props; + dispatch({ + type: "annotation: activate add new label mode", + data: metadataField + }); + }; + + disableAddNewLabelMode = () => { + const { dispatch } = this.props; + dispatch({ + type: "annotation: disable add new label mode" + }); + }; + + handleAddNewLabelToCategory = () => { + const { dispatch, metadataField } = this.props; + const { newLabelText } = this.state; + /* + XXX TODO - temporary code generates random label string. Remove + when the label creation UI is implemented. + + const { newLabelText } = this.state; + */ + // const newLabelText = `label${Math.random()}`; + dispatch({ + type: "annotation: add new label to category", + metadataField, + newLabelText + }); + this.setState({ newLabelText: "" }); + }; + + activateEditCategoryMode = () => { + const { dispatch, metadataField } = this.props; + dispatch({ + type: "annotation: activate category edit mode", + data: metadataField + }); + }; + + disableEditCategoryMode = () => { + const { dispatch } = this.props; + dispatch({ + type: "annotation: disable category edit mode" + }); + }; + + handleEditCategory = () => { + const { dispatch, metadataField } = this.props; + const { newCategoryText } = this.state; + + dispatch({ + type: "annotation: category edited", + metadataField, + newCategoryText, + data: newCategoryText + }); + }; + + handleDeleteCategory = () => { + const { dispatch, metadataField } = this.props; + dispatch({ + type: "annotation: delete category", + metadataField + }); + }; + handleColorChange = () => { const { dispatch, metadataField } = this.props; dispatch({ @@ -88,12 +172,13 @@ class Category extends React.Component { } renderCategoryItems() { - const { categoricalSelection, metadataField } = this.props; + const { categoricalSelection, metadataField, isUserAnno } = this.props; const cat = categoricalSelection[metadataField]; const optTuples = sortedCategoryValues([...cat.categoryValueIndices]); return _.map(optTuples, (tuple, i) => ( {""} - + {/* Dialog uses portal, can be anywhere/factored out */} + +
+
+

New, unique category name:

+ + this.setState({ newCategoryText: e.target.value }) + } + leftIcon="tag" + /> +
+
+
+
+ + + + +
+
+
+ {isUserAnno ? ( + + ) : null} {metadataField} {isExpanded ? ( - - + + + + + + + + + + + } + > +