From b00496198d8554f93517f3efc02a7a091ecf77f4 Mon Sep 17 00:00:00 2001 From: Bruce Martin Date: Tue, 2 Mar 2021 12:12:58 -0800 Subject: [PATCH] wire up geneset reducer (#2082) * first cut at GET /genesets route * update existing tests to match code changes * more GET /genesets and initial tests * add missing test fixture * geneset validation accepts OTA format * genesets route: better error handling, more tests * lint * genesets reducer and initial load * fix lint * add autosave support for genesets * remove debug logging * fix typo * fix another typo * update smoke test config for genesets * smoke test fixes * more fiddling with smoke tests --- client/__tests__/reducers/genesets.test.js | 524 ++++++++++++++++++ client/src/actions/annotation.js | 93 ++++ client/src/actions/index.js | 24 + client/src/components/autosave/index.js | 49 +- client/src/reducers/autosave.js | 55 +- client/src/reducers/genesets.js | 349 ++++++++++++ client/src/reducers/index.js | 3 + .../common/annotations/local_file_csv.py | 4 +- 8 files changed, 1082 insertions(+), 19 deletions(-) create mode 100644 client/__tests__/reducers/genesets.test.js create mode 100644 client/src/reducers/genesets.js diff --git a/client/__tests__/reducers/genesets.test.js b/client/__tests__/reducers/genesets.test.js new file mode 100644 index 00000000..7df76c6c --- /dev/null +++ b/client/__tests__/reducers/genesets.test.js @@ -0,0 +1,524 @@ +import genesetsReducer from "../../src/reducers/genesets"; + +describe("initial reducer state", () => { + test("some other action", () => { + expect(genesetsReducer(undefined, { type: "foo" })).toMatchObject({ + initialized: false, + lastTid: undefined, + genesets: new Map(), + }); + }); +}); + +describe("geneset: initial load", () => { + test("missing JSON response", () => { + expect(() => + genesetsReducer(undefined, { + type: "geneset: initial load", + }) + ).toThrow("missing or malformed JSON response"); + }); + + test("empty geneset", () => { + expect( + genesetsReducer(undefined, { + type: "geneset: initial load", + data: { + tid: 0, + genesets: [], + }, + }) + ).toMatchObject({ + initialized: true, + lastTid: 0, + genesets: new Map(), + }); + }); + + test("non-empty geneset", () => { + expect( + genesetsReducer(undefined, { + type: "geneset: initial load", + data: { + tid: 99, + genesets: [ + { + geneset_name: "G1", + genes: [{ gene_symbol: "F5" }], + }, + { + geneset_name: "G2", + geneset_description: "G2 desc", + genes: [{ gene_symbol: "F6" }], + }, + { + geneset_name: "G3", + geneset_description: "G3 desc", + genes: [{ gene_symbol: "F7", gene_description: "gene desc" }], + }, + ], + }, + }) + ).toMatchObject({ + initialized: true, + lastTid: 99, + genesets: new Map([ + [ + "G1", + { + genesetName: "G1", + genesetDescription: "", + genes: new Map([["F5", { geneSymbol: "F5", geneDescription: "" }]]), + }, + ], + [ + "G2", + { + genesetName: "G2", + genesetDescription: "G2 desc", + genes: new Map([["F6", { geneSymbol: "F6", geneDescription: "" }]]), + }, + ], + [ + "G3", + { + genesetName: "G3", + genesetDescription: "G3 desc", + genes: new Map([ + ["F7", { geneSymbol: "F7", geneDescription: "gene desc" }], + ]), + }, + ], + ]), + }); + }); +}); + +describe("geneset: create", () => { + const initialState = genesetsReducer(undefined, { + type: "geneset: initial load", + data: { + tid: 0, + genesets: [], + }, + }); + + test("simple create", () => { + expect( + genesetsReducer(initialState, { + type: "geneset: create", + genesetName: "a geneset", + genesetDescription: "", + }) + ).toMatchObject({ + ...initialState, + genesets: new Map([ + [ + "a geneset", + { + genesetName: "a geneset", + genesetDescription: "", + genes: new Map(), + }, + ], + ]), + }); + }); + + test("error - duplicate name", () => { + expect(() => { + genesetsReducer( + genesetsReducer(initialState, { + type: "geneset: create", + genesetName: "foo", + genesetDescription: "foo", + }), + { + type: "geneset: create", + genesetName: "foo", + genesetDescription: "bar", + } + ); + }).toThrow("name already defined"); + }); + + test("error - missing required action values", () => { + expect(() => { + genesetsReducer(initialState, { + type: "geneset: create", + genesetDescription: "foo", + }); + }).toThrow(); + expect(() => { + genesetsReducer(initialState, { + type: "geneset: create", + genesetName: "foo", + }); + }).toThrow("name or description unspecified"); + }); +}); + +describe("geneset: delete", () => { + const initialState = genesetsReducer(undefined, { + type: "geneset: initial load", + data: { + tid: 0, + genesets: [], + }, + }); + + test("simple delete", () => { + expect( + genesetsReducer( + genesetsReducer(initialState, { + type: "geneset: create", + genesetName: "foo", + genesetDescription: "foo", + }), + { + type: "geneset: delete", + genesetName: "foo", + } + ) + ).toMatchObject({ + initialized: true, + lastTid: 0, + genesets: new Map(), + }); + }); + + test("error - missing name", () => { + expect(() => { + genesetsReducer(initialState, { + type: "geneset: delete", + genesetName: "foo", + }); + }).toThrow("name does not exist"); + }); +}); + +describe("geneset: update", () => { + const initialState = genesetsReducer(undefined, { + type: "geneset: initial load", + data: { + tid: 0, + genesets: [], + }, + }); + + test("simple update", () => { + expect( + genesetsReducer( + genesetsReducer( + genesetsReducer(initialState, { + type: "geneset: create", + genesetName: "foo1", + genesetDescription: "foo1", + }), + { + type: "geneset: create", + genesetName: "foo2", + genesetDescription: "foo2", + } + ), + { + type: "geneset: update", + genesetName: "foo1", + update: { + genesetName: "bar", + genesetDescription: "bar", + }, + } + ) + ).toMatchObject({ + initialized: true, + lastTid: 0, + genesets: new Map([ + [ + "bar", + { genesetName: "bar", genesetDescription: "bar", genes: new Map() }, + ], + [ + "foo2", + { genesetName: "foo2", genesetDescription: "foo2", genes: new Map() }, + ], + ]), + }); + }); + + test("error - unknown name", () => { + expect(() => { + genesetsReducer(initialState, { + type: "geneset: update", + genesetName: "foo", + update: { + genesetName: "foo", + genesetDescription: "bar", + }, + }); + }).toThrow("name unspecified or does not exist"); + }); + + test("error - duplicate name", () => { + expect(() => { + genesetsReducer( + genesetsReducer(initialState, { + type: "geneset: create", + genesetName: "foo", + genesetDescription: "foo", + }), + { + type: "geneset: update", + genesetName: "foo", + update: { + genesetName: "foo", + genesetDescription: "foo", + }, + } + ); + }).toThrow("update specified existing name"); + }); +}); + +describe("geneset: add genes", () => { + const initialState = genesetsReducer( + genesetsReducer(undefined, { + type: "geneset: initial load", + data: { + tid: 0, + genesets: [], + }, + }), + { + type: "geneset: create", + genesetName: "test", + genesetDescription: "", + } + ); + + test("add a gene", () => { + expect( + genesetsReducer(initialState, { + type: "geneset: add genes", + genesetName: "test", + genes: [{ geneSymbol: "F5" }], + }) + ).toMatchObject({ + ...initialState, + genesets: new Map([ + [ + "test", + { + genesetName: "test", + genesetDescription: "", + genes: new Map([["F5", { geneSymbol: "F5", geneDescription: "" }]]), + }, + ], + ]), + }); + + expect( + genesetsReducer(initialState, { + type: "geneset: add genes", + genesetName: "test", + genes: [ + { geneSymbol: "F5", geneDescription: "desc" }, + { geneSymbol: "SET1", geneDescription: "" }, + ], + }) + ).toMatchObject({ + ...initialState, + genesets: new Map([ + [ + "test", + { + genesetName: "test", + genesetDescription: "", + genes: new Map([ + ["F5", { geneSymbol: "F5", geneDescription: "desc" }], + ["SET1", { geneSymbol: "SET1", geneDescription: "" }], + ]), + }, + ], + ]), + }); + }); + + test("no such geneset error", () => { + expect(() => { + genesetsReducer(initialState, { + type: "geneset: add genes", + genesetName: "mumble", + genes: [], + }); + }).toThrow("geneset name does not exist"); + }); +}); + +describe("geneset: delete genes", () => { + const initialState = genesetsReducer( + genesetsReducer( + genesetsReducer(undefined, { + type: "geneset: initial load", + data: { + tid: 0, + genesets: [], + }, + }), + { + type: "geneset: create", + genesetName: "test", + genesetDescription: "", + } + ), + { + type: "geneset: add genes", + genesetName: "test", + genes: [{ geneSymbol: "F5" }], + } + ); + + test("simple", () => { + expect( + genesetsReducer(initialState, { + type: "geneset: delete genes", + genesetName: "test", + geneSymbols: ["F5"], + }) + ).toMatchObject({ + ...initialState, + genesets: new Map([ + [ + "test", + { + genesetName: "test", + genesetDescription: "", + genes: new Map(), + }, + ], + ]), + }); + }); + + test("no such geneset error", () => { + expect(() => { + genesetsReducer(initialState, { + type: "geneset: delete genes", + genesetName: "mumble", + geneSymbols: [], + }); + }).toThrow("name does not exist"); + }); +}); + +describe("geneset: set gene description", () => { + const initialState = genesetsReducer( + genesetsReducer( + genesetsReducer(undefined, { + type: "geneset: initial load", + data: { + tid: 0, + genesets: [], + }, + }), + { + type: "geneset: create", + genesetName: "test", + genesetDescription: "", + } + ), + { + type: "geneset: add genes", + genesetName: "test", + genes: [{ geneSymbol: "F5" }], + } + ); + + test("simple set", () => { + expect( + genesetsReducer(initialState, { + type: "geneset: set gene description", + genesetName: "test", + update: { + geneSymbol: "F5", + geneDescription: "mumble", + }, + }) + ).toMatchObject({ + ...initialState, + genesets: new Map([ + [ + "test", + { + genesetName: "test", + genesetDescription: "", + genes: new Map([ + ["F5", { geneSymbol: "F5", geneDescription: "mumble" }], + ]), + }, + ], + ]), + }); + }); + + test("no such geneset error", () => { + expect(() => { + genesetsReducer(initialState, { + type: "geneset: set gene description", + genesetName: "does not exist", + update: { + geneSymbol: "F5", + geneDescription: "mumble", + }, + }); + }).toThrow("geneset name does not exist"); + }); + + test("no such gene error", () => { + expect(() => { + genesetsReducer(initialState, { + type: "geneset: set gene description", + genesetName: "test", + update: { + geneSymbol: "NO SUCH GENE", + geneDescription: "mumble", + }, + }); + }).toThrow("no such gene"); + }); +}); + +describe("geneset: set tid", () => { + test("simple set", () => { + expect( + genesetsReducer(undefined, { + type: "geneset: set tid", + tid: 1, + }) + ).toMatchObject({ lastTid: 1 }); + }); + + test("not a number error", () => { + expect(() => { + genesetsReducer( + { lastTid: 1 }, + { + type: "geneset: set tid", + tid: "0", + } + ); + }).toThrow("must be a positive integer"); + }); + + test("decrement error", () => { + expect(() => { + genesetsReducer( + { lastTid: 1 }, + { + type: "geneset: set tid", + tid: 0, + } + ); + }).toThrow("may not be decremented"); + }); +}); diff --git a/client/src/actions/annotation.js b/client/src/actions/annotation.js index 136fe9b3..7b36a51b 100644 --- a/client/src/actions/annotation.js +++ b/client/src/actions/annotation.js @@ -387,3 +387,96 @@ export const saveObsAnnotationsAction = () => async (dispatch, getState) => { }); } }; + +export const saveGenesetsAction = () => async (dispatch, getState) => { + const state = getState(); + + // bail if gene sets not available, or in readonly mode. + const { config } = state; + const genesetsAreAvailable = + config?.parameters?.["annotations_genesets"] ?? false; + const genesetsReadonly = + config?.parameters?.["annotations_genesets_readonly"] ?? true; + if (!genesetsAreAvailable || genesetsReadonly) { + // our non-save was completed! + return dispatch({ + type: "autosave: genesets complete", + lastSavedGenesets: state.genesets, + }); + } + + const { lastTid, genesets: lastGenesets } = state.genesets; + + /* Create the JSON OTA data structure */ + const tid = (lastTid ?? 0) + 1; + const genesets = []; + for (const [name, gs] of lastGenesets) { + // const genes = Array.from(gs.genes.values()); + const genes = []; + for (const g of gs.genes.values()) { + genes.push({ + gene_symbol: g.geneSymbol, + gene_description: g.geneDescription, + }); + } + genesets.push({ + geneset_name: name, + geneset_description: gs.genesetDescription, + genes, + }); + } + const ota = { + tid, + genesets, + }; + + /* Save to server */ + try { + const { + dataCollectionNameIsReadOnly, + dataCollectionName, + } = state.annotations; + const queryString = + !dataCollectionNameIsReadOnly && !!dataCollectionName + ? `?annotation-collection-name=${encodeURIComponent( + dataCollectionName + )}` + : ""; + + const res = await fetch( + `${globals.API.prefix}${globals.API.version}genesets${queryString}`, + { + method: "PUT", + headers: new Headers({ + Accept: "application/json", + "Content-Type": "application/json", + }), + body: JSON.stringify(ota), + credentials: "include", + } + ); + if (!res.ok) { + return dispatch({ + type: "autosave: genesets error", + message: `HTTP error ${res.status} - ${res.statusText}`, + res, + }); + } + return Promise.all([ + dispatch({ + type: "autosave: genesets complete", + lastSavedGenesets: genesets, + }), + dispatch({ + type: "geneset: set tid", + tid, + }), + ]); + } catch (error) { + return dispatch({ + type: "autosave: genesets error", + message: error.toString(), + error, + }); + } +}; diff --git a/client/src/actions/index.js b/client/src/actions/index.js index a71149ee..026b2143 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -52,6 +52,27 @@ async function userInfoFetch(dispatch) { }); } +async function genesetsFetch(dispatch, config) { + /* request genesets ONLY if the backend supports the feature */ + const defaultResponse = { + genesets: [], + tid: 0, + }; + if (config?.parameters?.["annotations_genesets"] ?? false) { + fetchJson("genesets").then((response) => { + dispatch({ + type: "geneset: initial load", + data: response ?? defaultResponse, + }); + }); + } else { + dispatch({ + type: "geneset: initial load", + data: defaultResponse, + }); + } +} + function prefetchEmbeddings(annoMatrix) { /* prefetch requests for all embeddings @@ -76,6 +97,8 @@ const doInitialDataLoad = () => userInfoFetch(dispatch), ]); + genesetsFetch(dispatch, config); + const baseDataUrl = `${globals.API.prefix}${globals.API.version}`; const annoMatrix = new AnnoMatrixLoader(baseDataUrl, schema.schema); const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix); @@ -242,6 +265,7 @@ export default { annotationRenameLabelInCategory: annoActions.annotationRenameLabelInCategory, annotationLabelCurrentSelection: annoActions.annotationLabelCurrentSelection, saveObsAnnotationsAction: annoActions.saveObsAnnotationsAction, + saveGenesetsAction: annoActions.saveGenesetsAction, needToSaveObsAnnotations: annoActions.needToSaveObsAnnotations, layoutChoiceAction: embActions.layoutChoiceAction, setCellSetFromSelection: selnActions.setCellSetFromSelection, diff --git a/client/src/components/autosave/index.js b/client/src/components/autosave/index.js index c03cdfd5..18b5f152 100644 --- a/client/src/components/autosave/index.js +++ b/client/src/components/autosave/index.js @@ -5,11 +5,18 @@ import FilenameDialog from "./filenameDialog"; @connect((state) => ({ annotations: state.annotations, - saveInProgress: state.autosave?.saveInProgress ?? false, + obsAnnotationSaveInProgress: + state.autosave?.obsAnnotationSaveInProgress ?? false, + genesetSaveInProgress: state.autosave?.genesetSaveInProgress ?? false, error: state.autosave?.error, writableCategoriesEnabled: state.config?.parameters?.annotations ?? false, + writableGenesetsEnabled: !( + state.config?.parameters?.["annotations_genesets_readonly"] ?? true + ), annoMatrix: state.annoMatrix, + genesets: state.genesets, lastSavedAnnoMatrix: state.autosave?.lastSavedAnnoMatrix, + lastSavedGenesets: state.autosave?.lastSavedGenesets, })) class Autosave extends React.Component { constructor(props) { @@ -20,11 +27,11 @@ class Autosave extends React.Component { } componentDidMount() { - const { writableCategoriesEnabled } = this.props; + const { writableCategoriesEnabled, writableGenesetsEnabled } = this.props; let { timer } = this.state; if (timer) clearInterval(timer); - if (writableCategoriesEnabled) { + if (writableCategoriesEnabled || writableGenesetsEnabled) { timer = setInterval(this.tick, 2500); } else { timer = null; @@ -38,18 +45,40 @@ class Autosave extends React.Component { } tick = () => { - const { dispatch, saveInProgress } = this.props; - if (this.needToSave() && !saveInProgress) { + const { + dispatch, + obsAnnotationSaveInProgress, + genesetSaveInProgress, + } = this.props; + if (!obsAnnotationSaveInProgress && this.needToSaveObsAnnotations()) { dispatch(actions.saveObsAnnotationsAction()); } + if (!genesetSaveInProgress && this.needToSaveGenesets()) { + dispatch(actions.saveGenesetsAction()); + } }; - needToSave = () => { - /* return true if we need to save, false if we don't */ + needToSaveObsAnnotations = () => { + /* return true if we need to save obs cell labels, false if we don't */ const { annoMatrix, lastSavedAnnoMatrix } = this.props; return actions.needToSaveObsAnnotations(annoMatrix, lastSavedAnnoMatrix); }; + 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; + }; + + needToSave() { + return this.needToSaveGenesets() || this.needToSaveObsAnnotations(); + } + + saveInProgress() { + const { obsAnnotationSaveInProgress, genesetSaveInProgress } = this.props; + return obsAnnotationSaveInProgress || genesetSaveInProgress; + } + statusMessage() { const { error } = this.props; if (error) { @@ -61,12 +90,12 @@ class Autosave extends React.Component { render() { const { writableCategoriesEnabled, - saveInProgress, + writableGenesetsEnabled, lastSavedAnnoMatrix, } = this.props; const initialDataLoadComplete = lastSavedAnnoMatrix; - if (!writableCategoriesEnabled) return null; + if (!writableCategoriesEnabled && !writableGenesetsEnabled) return null; return (
{ switch (action.type) { case "annoMatrix: init complete": { return { ...state, error: false, - saveInProgress: false, + obsAnnotationSaveInProgress: false, lastSavedAnnoMatrix: action.annoMatrix, }; } @@ -19,7 +27,7 @@ const Autosave = ( case "writable obs annotations - save started": { return { ...state, - saveInProgress: true, + obsAnnotationSaveInProgress: true, }; } @@ -27,7 +35,7 @@ const Autosave = ( return { ...state, error: action.message, - saveInProgress: false, + obsAnnotationSaveInProgress: false, }; } @@ -35,12 +43,45 @@ const Autosave = ( const { lastSavedAnnoMatrix } = action; return { ...state, - saveInProgress: false, + obsAnnotationSaveInProgress: false, error: false, lastSavedAnnoMatrix, }; } + case "geneset: initial load": { + return { + ...state, + genesetSaveInProgress: false, + lastSavedGenesets: nextSharedState.genesets, + }; + } + + case "autosave: genesets started": { + return { + ...state, + genesetSaveInProgress: true, + }; + } + + case "autosave: genesets error": { + return { + ...state, + genesetSaveInProgress: false, + error: action.message, + }; + } + + case "autosave: genesets complete": { + const { lastSavedGenesets } = action; + return { + ...state, + genesetSaveInProgess: false, + error: false, + lastSavedGenesets, + }; + } + default: return { ...state }; } diff --git a/client/src/reducers/genesets.js b/client/src/reducers/genesets.js new file mode 100644 index 00000000..56458330 --- /dev/null +++ b/client/src/reducers/genesets.js @@ -0,0 +1,349 @@ +/** + * Gene set state. Geneset UI state is in a different reducer. + * + * geneset reducer state is a Map object, where: + * key: the geneset name, a string. + * val: the geneset defined as an object ("geneset object") + * + * A geneset object is: + * { + * genesetName: # same as the map key + * genesetDescription: + * genes: Map<, { + * geneSymbol: , # same as the map key + * geneDescription: + * }> + * } + * + * Geneset and genes Map order is significant, and will be preserved across + * CRUD operations on either. + * + * This reducer does light error checking, but not as much as the backend + * routes. Do not rely on it to enforce geneset integrity - eg, no duplicate + * genes in a geneset. + */ +const GeneSets = ( + state = { + initialized: false, + lastTid: undefined, + genesets: new Map(), + }, + action +) => { + switch (action.type) { + /** + * Initial, load-time bootstrap. + * { + * type: "geneset: initial load" + * data: JSON response + * } + */ + case "geneset: initial load": { + const { data } = action; + + if ( + !data || + typeof data.tid !== "number" || + !Array.isArray(data.genesets) + ) + throw new Error("missing or malformed JSON response"); + + const lastTid = data.tid; + const genesetsData = data.genesets; + const genesets = new Map(); + + for (const gsData of genesetsData) { + const genes = new Map(); + for (const gene of gsData.genes) { + genes.set(gene.gene_symbol, { + geneSymbol: gene.gene_symbol, + geneDescription: gene?.["gene_description"] ?? "", + }); + } + const gs = { + genesetName: gsData.geneset_name, + genesetDescription: gsData?.["geneset_description"] ?? "", + genes, + }; + genesets.set(gsData.geneset_name, gs); + } + + return { + initialized: true, + lastTid, + genesets, + }; + } + + /** + * Creates a new & empty geneset with the given name and description. + * { + * type: "geneset: create", + * genesetName: string, // gene set name + * genesetDescription: string, // geneset description + * } + * + */ + case "geneset: create": { + const { genesetName, genesetDescription } = action; + if ( + typeof genesetName !== "string" || + !genesetName || + genesetDescription === undefined + ) + throw new Error("geneset: create -- name or description unspecified."); + if (state.genesets.has(genesetName)) + throw new Error("geneset: create -- name already defined."); + + const genesets = new Map(state.genesets); // clone + genesets.set(genesetName, { + genesetName, + genesetDescription, + genes: new Map(), + }); + + return { + ...state, + genesets, + }; + } + + /** + * Deletes the named geneset, if it exists. Throws if it does not. + * { + * type: "geneset: delete", + * genesetName: string + * } + */ + case "geneset: delete": { + const { genesetName } = action; + if (!state.genesets.has(genesetName)) + throw new Error("geneset: delete -- geneset name does not exist."); + + const genesets = new Map(state.genesets); // clone + genesets.delete(genesetName); + return { + ...state, + genesets, + }; + } + + /** + * Update the named geneset with a new name and description. Preserves the existing + * order of the geneset, even when the genesetName changes. + * { + * type: "geneset: update", + * genesetName: string, current name of geneset to be updated + * update: { + * genesetName: string, new name + * genesetDescription: string, new description + * } + * } + * + * For example, if you want to update JUST the description: + * dispatch({ + * action: "geneset: update", + * genesetName: "foo", + * update: { genesetName: "foo", genesetDescription: "a new description"} + * }) + */ + case "geneset: update": { + const { genesetName, update } = action; + if ( + typeof genesetName !== "string" || + !genesetName || + !state.genesets.has(genesetName) + ) + throw new Error( + "geneset: update -- geneset name unspecified or does not exist." + ); + if (state.genesets.has(update.genesetName)) + throw new Error("geneset: update -- update specified existing name."); + + const prevGs = state.genesets.get(genesetName); + const newGs = { + ...update, + genes: prevGs.genes, + }; // clone + + // clone the map, preserving current insert order, but mapping name->newName. + const genesets = new Map(); + for (const [name, gs] of state.genesets) { + if (name === genesetName) genesets.set(newGs.genesetName, newGs); + else genesets.set(name, gs); + } + + return { + ...state, + genesets, + }; + } + + /** + * Adds genes to the geneset. They are appended to the END of the geneset, in the + * order provided. Duplicates or genes already in the geneset, will be ignored. + * { + * type: "geneset: add genes" + * genesetName: , // gene set name + * genes: Array<{ + * geneSymbol: , + * geneDescription: + * }> + * } + * + * Example: + * dispatch({ + * type: "add genes", + * genesetName: "foo", + * genes: [ { geneSymbol: "FOXP", geneDescription: "test" }] + * }); + */ + case "geneset: add genes": { + const { genesetName, genes } = action; + if (!state.genesets.has(genesetName)) + throw new Error("geneset: add genes -- geneset name does not exist."); + + // clone + const genesets = new Map(state.genesets); + const gs = { + ...genesets.get(genesetName), + genes: new Map(genesets.get(genesetName).genes), + }; + genesets.set(genesetName, gs); + + // add + const newGenes = gs.genes; + for (const gene of genes) { + const { geneSymbol } = gene; + const geneDescription = gene?.geneDescription ?? ""; + // ignore genes already present + if (!newGenes.has(geneSymbol)) + newGenes.set(geneSymbol, { + geneSymbol, + geneDescription, + }); + } + + return { + ...state, + genesets, + }; + } + + /** + * Delete genes from the named geneset. Will throw if the genesetName does + * not exist. Will ignore geneSymbols that do not exist. + * { + * type: "geneset: delete genes", + * genesetName: , // the geneset from which to delete genes + * geneSymbols: [, ...], // the gene symbols to delete. + * } + * + * Example: + * dispatch({ + * type: "geneset: delete genes", + * genesetName: "a geneset name", + * geneSymbols: ["F5"] + * }) + */ + case "geneset: delete genes": { + const { genesetName, geneSymbols } = action; + if (!state.genesets.has(genesetName)) + throw new Error( + "geneset: delete genes -- geneset name does not exist." + ); + + // clone + const genesets = new Map(state.genesets); + const gs = { + ...genesets.get(genesetName), + genes: new Map(genesets.get(genesetName).genes), + }; + genesets.set(genesetName, gs); + + // delete + const { genes } = gs; + for (const geneSymbol of geneSymbols) { + genes.delete(geneSymbol); + } + return { + ...state, + genesets, + }; + } + + /** + * Set/update the description of the gene. NOTE that this does not allow the name + * of the gene to change - only "geneset: add" and "geneset: delete" can change + * the genes in a geneset. Use this to update a gene description AFTER you add it + * to the geneset. + * { + * type: "geneset: set gene description", + * genesetName: , // the geneset to update + * update: { + * geneSymbol: , // the gene to update, MUST exist already in the geneset + * geneDescription: + * } + * } + * + * Example: + * dispatch({ + * type: "geneset: set gene description", + * genesetName: "my fav geneset", + * update: { + * geneSymbol: "F5", + * geneDescription: "tada, moar description" + * } + * }) + */ + case "geneset: set gene description": { + const { genesetName, update } = action; + if (!state.genesets.has(genesetName)) + throw new Error( + "geneset: set gene description -- geneset name does not exist." + ); + + // clone + const genesets = new Map(state.genesets); + const gs = { + ...genesets.get(genesetName), + genes: new Map(genesets.get(genesetName).genes), + }; + genesets.set(genesetName, gs); + + const { geneSymbol, geneDescription } = update; + const gene = gs.genes.get(geneSymbol); + if (!gene) + throw new Error("geneset: set gene description -- no such gene"); + gs.genes.set(geneSymbol, { + geneSymbol, + geneDescription, + }); + + return { + ...state, + genesets, + }; + } + + /** + * Used by autosave to update the server synchronization TID + */ + case "geneset: set tid": { + const { tid } = action; + if (!Number.isInteger(tid) || tid < 0) + throw new Error("TID must be a positive integer number"); + if (state.lastTid !== undefined && tid < state.lastTid) + throw new Error("TID may not be decremented."); + return { + ...state, + lastTid: tid, + }; + } + + default: + return state; + } +}; + +export default GeneSets; diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 64a4a48b..e1def811 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -15,6 +15,7 @@ import differential from "./differential"; import layoutChoice from "./layoutChoice"; import controls from "./controls"; import annotations from "./annotations"; +import genesets from "./genesets"; import autosave from "./autosave"; import ontology from "./ontology"; import centroidLabels from "./centroidLabels"; @@ -31,6 +32,7 @@ const Reducer = undoable( ["obsCrossfilter", obsCrossfilter], ["ontology", ontology], ["annotations", annotations], + ["genesets", genesets], ["layoutChoice", layoutChoice], ["categoricalSelection", categoricalSelection], ["continuousSelection", continuousSelection], @@ -55,6 +57,7 @@ const Reducer = undoable( "differential", "layoutChoice", "centroidLabels", + "genesets", "annotations", ], undoableConfig diff --git a/local_server/common/annotations/local_file_csv.py b/local_server/common/annotations/local_file_csv.py index d23a3f0d..fb12ca3c 100644 --- a/local_server/common/annotations/local_file_csv.py +++ b/local_server/common/annotations/local_file_csv.py @@ -108,7 +108,7 @@ class AnnotationsLocalFile(Annotations): def read_genesets(self, data_adaptor, context=None): if has_request_context(): if not current_app.auth.is_user_authenticated(): - return ([], None) + return ({}, self.last_geneset_tid) fname = self._get_genesets_filename(data_adaptor) genesets = {} @@ -165,7 +165,7 @@ class AnnotationsLocalFile(Annotations): output_file = self.label_output_file or self.genesets_output_file if output_file: - return os.path.dirname(self.path.abspath(output_file)) + return os.path.dirname(os.path.abspath(output_file)) return os.getcwd()