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 (