diff --git a/src/RootContext.ts b/src/RootContext.ts index dbad753..453e706 100644 --- a/src/RootContext.ts +++ b/src/RootContext.ts @@ -14,6 +14,7 @@ export const initialState: AppState = { export enum ActionType { ERROR_ADDED = "ERROR_ADDED", ERROR_DISMISSED = "ERROR_DISMISSED", + CLEAR_ALL_ERRORS = "CLEAR_ALL_ERRORS", UPLOAD_ERROR_ADDED = "UPLOAD_ERROR_ADDED", UPLOAD_ERROR_DISMISSED = "UPLOAD_ERROR_DISMISSED", DATASET_NAMES_FETCHED = "DATASET_NAMES_FETCHED", @@ -21,7 +22,8 @@ export enum ActionType { DATASET_SELECTED = "DATASET_SELECTED", SELECT_COVARIATE = "SELECT_COVARIATE", UNSELECT_COVARIATE = "UNSELECT_COVARIATE", - SELECT_SCALE = "SELECT_SCALE" + SELECT_SCALE = "SELECT_SCALE", + SET_SPLINE_OPTIONS = "SET_SPLINE_OPTIONS", } export interface RootAction { diff --git a/src/components/App.tsx b/src/components/App.tsx index d565cda..c2aef2b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -24,6 +24,7 @@ export default function App() { } }, [state.selectedDataset, state.language, dispatch]); + useEffect(() => { setInterval(() => { dataService("en", () => { diff --git a/src/components/ExploreDataset.tsx b/src/components/ExploreDataset.tsx index a9a2b36..bd5d2c2 100644 --- a/src/components/ExploreDataset.tsx +++ b/src/components/ExploreDataset.tsx @@ -3,7 +3,7 @@ import {RootContext} from "../RootContext"; import {Col, Row} from "react-bootstrap"; import SideBar from "./SideBar"; import LinePlot from "./LinePlot"; -import {calculateFacets} from "../services/plotUtils"; +import {calculateFacets} from "../services/utils"; export function ExploreDataset() { diff --git a/src/components/LinePlot.tsx b/src/components/LinePlot.tsx index 6eec4f4..2179cb9 100644 --- a/src/components/LinePlot.tsx +++ b/src/components/LinePlot.tsx @@ -1,9 +1,10 @@ -import React, {useContext, useEffect, useState} from 'react'; +import React, {useContext, useState} from 'react'; import Plot from 'react-plotly.js'; import {RootContext, RootDispatchContext} from "../RootContext"; import {DataSeries} from "../generated"; import {dataService} from "../services/dataService"; -import {toFilename} from "../services/plotUtils"; +import {toFilename} from "../services/utils"; +import {useDebouncedEffect} from "../hooks/useDebouncedEffect"; interface Props { biomarker: string @@ -45,11 +46,12 @@ export default function LinePlot({ const covariateSettings = state.datasetSettings[state.selectedDataset].covariateSettings; const scale = state.datasetSettings[state.selectedDataset].scale; - useEffect(() => { + const splineSettings = state.datasetSettings[state.selectedDataset].splineSettings; + useDebouncedEffect(() => { const fetchData = async () => { const result = await dataService(state.language, dispatch) .getDataSeries(state.selectedDataset, - biomarker, facetDefinition, covariateSettings, scale); + biomarker, facetDefinition, covariateSettings, scale, splineSettings); if (result && result.data) { setSeries(result.data) @@ -58,7 +60,7 @@ export default function LinePlot({ } } fetchData(); - }, [state.language, dispatch, state.selectedDataset, biomarker, facetDefinition, covariateSettings, scale]); + }, [state.language, dispatch, state.selectedDataset, biomarker, facetDefinition, covariateSettings, scale, splineSettings], 100); let series: any[] = []; diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 2951340..eeed3ff 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -6,6 +6,7 @@ import CovariateOptions from "./CovariateOptions"; import SelectedCovariate from "./SelectedCovariate"; import ChooseDataset from "./ChooseDataset"; import ChooseScale from "./ChooseScale"; +import SplineOptions from "./SplineOptions"; export default function SideBar() { @@ -39,6 +40,10 @@ export default function SideBar() { + + Spline options + + {availableCovariates.length > 0 && Disaggregate by diff --git a/src/components/SplineOptions.tsx b/src/components/SplineOptions.tsx new file mode 100644 index 0000000..595fd61 --- /dev/null +++ b/src/components/SplineOptions.tsx @@ -0,0 +1,82 @@ +import React, {useContext} from "react"; +import {ActionType, RootContext, RootDispatchContext} from "../RootContext"; +import Form from "react-bootstrap/Form"; +import {Col, Row} from "react-bootstrap"; +import {between} from "../services/utils"; + +export default function SplineOptions() { + + const state = useContext(RootContext); + const dispatch = useContext(RootDispatchContext); + + const onChangeMethod = (event: any) => { + dispatch({ + type: ActionType.SET_SPLINE_OPTIONS, + payload: {method: event.target.value} + }); + } + + const onChangeSpan = (event: any) => { + dispatch({ + type: ActionType.SET_SPLINE_OPTIONS, + payload: {span: between(event.target.value, 0, 1)} + }); + } + + const onChangeKnots = (event: any) => { + dispatch({ + type: ActionType.SET_SPLINE_OPTIONS, + payload: {k: between(event.target.value, 5, 30)} + }); + } + + const settings = state.datasetSettings[state.selectedDataset].splineSettings; + + return + + + Method: + + + + + + + + + + + + Span: + + + + + + + + + k: + + + + + + + +} diff --git a/src/hooks/useDebouncedEffect.ts b/src/hooks/useDebouncedEffect.ts new file mode 100644 index 0000000..8f417ef --- /dev/null +++ b/src/hooks/useDebouncedEffect.ts @@ -0,0 +1,10 @@ +import { useEffect } from "react"; + +export const useDebouncedEffect = (effect: () => void, deps: any[], delay: number) => { + useEffect(() => { + const handler = setTimeout(() => effect(), delay); + + return () => clearTimeout(handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...(deps || []), delay]); +} diff --git a/src/reducers/datasetReducer.ts b/src/reducers/datasetReducer.ts index d72708e..b0abe40 100644 --- a/src/reducers/datasetReducer.ts +++ b/src/reducers/datasetReducer.ts @@ -1,4 +1,4 @@ -import {AppState, DatasetSettings} from "../types"; +import {AppState, DatasetSettings, SplineSettings} from "../types"; import {ActionType, RootAction} from "../RootContext"; export const datasetReducer = (state: AppState, action: RootAction): AppState => { @@ -13,14 +13,23 @@ export const datasetReducer = (state: AppState, action: RootAction): AppState => return unselectCovariate(state, action) case ActionType.SELECT_SCALE: return selectScale(state, action) + case ActionType.SET_SPLINE_OPTIONS: + return setSpline(state, action) default: return state } } +const splineSettings = (): SplineSettings => ({ + method: "auto", + span: 0.75, + k: 10 +}) + const datasetSettings = (): DatasetSettings => ({ covariateSettings: [], - scale: "natural" + scale: "natural", + splineSettings: splineSettings() }) const selectDataset = (state: AppState, action: RootAction): AppState => { @@ -53,3 +62,10 @@ const selectScale = (state: AppState, action: RootAction): AppState => { newState.datasetSettings[state.selectedDataset].scale = action.payload return newState } + +const setSpline = (state: AppState, action: RootAction): AppState => { + const newState = {...state} + const settings = newState.datasetSettings[state.selectedDataset].splineSettings; + newState.datasetSettings[state.selectedDataset].splineSettings = {...settings, ...action.payload} + return newState +} diff --git a/src/reducers/rootReducer.ts b/src/reducers/rootReducer.ts index 2abe2bf..1cc71fe 100644 --- a/src/reducers/rootReducer.ts +++ b/src/reducers/rootReducer.ts @@ -15,6 +15,11 @@ export const rootReducer = (state: AppState, action: RootAction): AppState => { ...state, genericErrors: state.genericErrors.filter(e => e.detail !== action.payload.detail || e.error !== action.payload.error) } + case ActionType.CLEAR_ALL_ERRORS: + return { + ...state, + genericErrors: [] + } case ActionType.UPLOAD_ERROR_ADDED: return {...state, uploadError: action.payload} case ActionType.UPLOAD_ERROR_DISMISSED: diff --git a/src/services/apiService.ts b/src/services/apiService.ts index 336dc63..592c314 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -25,7 +25,6 @@ export interface API { postAndReturn(url: string, data: any): Promise> get(url: string): Promise> - delete(url: string): Promise } export class APIService implements API { @@ -120,6 +119,10 @@ export class APIService implements API { this._dispatch({type: ActionType.ERROR_ADDED, payload: error}); }; + private _clearErrors = () => { + this._dispatch({type: ActionType.CLEAR_ALL_ERRORS, payload: null}); + }; + private _verifyHandlers(url: string) { if (this._onError == null && !this._ignoreErrors) { console.warn(`No error handler registered for request ${url}.`) @@ -131,6 +134,7 @@ export class APIService implements API { async get(url: string): Promise> { this._verifyHandlers(url); + this._clearErrors(); const fullUrl = this._buildFullUrl(url); return this._handleAxiosResponse(axios.get(fullUrl, {headers: this._headers})); } @@ -147,6 +151,7 @@ export class APIService implements API { async postAndReturn(url: string, data?: any): Promise> { this._verifyHandlers(url); + this._clearErrors(); const fullUrl = this._buildFullUrl(url); // this allows us to pass data of type FormData in both the browser and @@ -158,11 +163,6 @@ export class APIService implements API { return this._handleAxiosResponse(axios.post(fullUrl, data, {headers})); } - async delete(url: string) { - const fullUrl = this._buildFullUrl(url); - return this._handleAxiosResponse(axios.delete(fullUrl)); - } - } export const api = (lang: string, dispatch: (action: RootAction) => void) => new APIService(lang, dispatch); diff --git a/src/services/dataService.ts b/src/services/dataService.ts index c34c2bb..5b97b14 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -7,7 +7,7 @@ import { UploadResult } from "../generated"; import { - GenericResponse, CovariateSettings, + GenericResponse, CovariateSettings, SplineSettings, } from "../types"; import {Dispatch} from "react"; @@ -51,7 +51,8 @@ export class DataService { biomarker: string, facetDefinition: string, covariateSettings: CovariateSettings[], - scale: "log" | "natural" | "log2") { + scale: "log" | "natural" | "log2", + splineSettings: SplineSettings) { const traces = covariateSettings @@ -67,7 +68,7 @@ export class DataService { queryString += `disaggregate=${encodeURIComponent(traces)}&` } - queryString += `scale=${scale}` + queryString += `scale=${scale}&method=${splineSettings.method}&span=${splineSettings.span}&k=${splineSettings.k}` return await this._api .ignoreSuccess() diff --git a/src/services/plotUtils.ts b/src/services/utils.ts similarity index 79% rename from src/services/plotUtils.ts rename to src/services/utils.ts index 7f388af..585ac33 100644 --- a/src/services/plotUtils.ts +++ b/src/services/utils.ts @@ -10,3 +10,7 @@ export const toFilename = (title: string) => title .replaceAll(/\W+/g, " ") .trim() .replaceAll(/\s+/g, "_") + +export const between = (x: number, min: number, max: number) => { + return Math.min(Math.max(x, min), max) +} diff --git a/src/types.ts b/src/types.ts index 3aad6d0..91440b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,9 +23,16 @@ export interface CovariateSettings extends Variable { display: PlotDisplay } +export interface SplineSettings { + method: "gam" | "loess" | "auto" + k: number + span: number +} + export interface DatasetSettings { covariateSettings: CovariateSettings[] scale: "log" | "natural" | "log2" + splineSettings: SplineSettings } export interface AppState { diff --git a/test/components/ChooseDataset.test.tsx b/test/components/ChooseDataset.test.tsx index 343cb9c..da124ed 100644 --- a/test/components/ChooseDataset.test.tsx +++ b/test/components/ChooseDataset.test.tsx @@ -27,9 +27,9 @@ describe("", () => { ); - await waitFor(() => expect(dispatch.mock.calls.length).toBe(1)); + await waitFor(() => expect(dispatch.mock.calls.length).toBe(2)); - expect(dispatch.mock.calls[0][0]).toEqual({ + expect(dispatch.mock.calls[1][0]).toEqual({ type: ActionType.DATASET_NAMES_FETCHED, payload: ["d1", "d2"] }); @@ -98,7 +98,7 @@ describe("", () => { const submit = screen.getByText("Go"); await user.click(submit); - expect(dispatch.mock.calls[1][0]).toEqual({ + expect(dispatch.mock.calls[2][0]).toEqual({ type: ActionType.DATASET_SELECTED, payload: "d2" }); @@ -152,11 +152,14 @@ describe("", () => { const testFile = new File(['hello'], 'hello.csv', {type: 'text/csv'}); await user.upload(fileInput, testFile); - expect(dispatch.mock.calls.length).toBe(4); - expect(dispatch.mock.calls[0][0].type).toBe(ActionType.DATASET_NAMES_FETCHED); - expect(dispatch.mock.calls[1][0].type).toBe(ActionType.UPLOAD_ERROR_DISMISSED); - expect(dispatch.mock.calls[2][0].type).toBe(ActionType.DATASET_NAMES_FETCHED); - expect(dispatch.mock.calls[3][0].type).toBe(ActionType.DATASET_SELECTED); - expect(dispatch.mock.calls[3][0].payload).toBe("hello"); + expect(dispatch.mock.calls.length).toBe(7); + expect(dispatch.mock.calls[0][0].type).toBe(ActionType.CLEAR_ALL_ERRORS); + expect(dispatch.mock.calls[1][0].type).toBe(ActionType.DATASET_NAMES_FETCHED); + expect(dispatch.mock.calls[2][0].type).toBe(ActionType.UPLOAD_ERROR_DISMISSED); + expect(dispatch.mock.calls[3][0].type).toBe(ActionType.CLEAR_ALL_ERRORS); + expect(dispatch.mock.calls[4][0].type).toBe(ActionType.CLEAR_ALL_ERRORS); + expect(dispatch.mock.calls[5][0].type).toBe(ActionType.DATASET_NAMES_FETCHED); + expect(dispatch.mock.calls[6][0].type).toBe(ActionType.DATASET_SELECTED); + expect(dispatch.mock.calls[6][0].payload).toBe("hello"); }); }); diff --git a/test/components/LinePlot.test.tsx b/test/components/LinePlot.test.tsx index 197dbf2..6449b89 100644 --- a/test/components/LinePlot.test.tsx +++ b/test/components/LinePlot.test.tsx @@ -24,7 +24,7 @@ describe("", () => { }); test("requests data for given biomarker", async () => { - mockAxios.onGet(`/dataset/d1/trace/ab/?scale=natural`) + mockAxios.onGet(`/dataset/d1/trace/ab/?scale=natural&method=auto&span=0.75&k=10`) .reply(200, mockSuccess([{ name: "all", model: { @@ -94,7 +94,7 @@ describe("", () => { }); test("requests data for given facet variables", async () => { - mockAxios.onGet(`/dataset/d1/trace/ab/?filter=age%3A0%2Bsex%3AF&scale=natural`) + mockAxios.onGet(`/dataset/d1/trace/ab/?filter=age%3A0%2Bsex%3AF&scale=natural&method=auto&span=0.75&k=10`) .reply(200, mockSuccess([{ name: "all", model: { @@ -164,7 +164,7 @@ describe("", () => { }); test("clears plot data if request to API fails", async () => { - mockAxios.onGet("/dataset/d1/trace/ab/?scale=natural") + mockAxios.onGet("/dataset/d1/trace/ab/?scale=natural&method=auto&span=0.75&k=10") .reply(200, mockSuccess([{ name: "all", model: { @@ -177,7 +177,7 @@ describe("", () => { } }])); - mockAxios.onGet("/dataset/d1/trace/ab/?filter=sex%3AF&scale=natural") + mockAxios.onGet("/dataset/d1/trace/ab/?filter=sex%3AF&scale=natural&method=auto&span=0.75&k=10") .reply(404, mockFailure("bad")); const dispatch = jest.fn(); diff --git a/test/components/SideBar.test.tsx b/test/components/SideBar.test.tsx index d4c5a76..bbd19aa 100644 --- a/test/components/SideBar.test.tsx +++ b/test/components/SideBar.test.tsx @@ -37,7 +37,7 @@ describe("", () => { ); - const selectVariable = screen.getAllByRole("listbox")[0] as HTMLSelectElement; + const selectVariable = screen.getAllByRole("listbox")[1] as HTMLSelectElement; let items = selectVariable.options; expect(items.length).toBe(2); expect(items[0].value).toBe("b"); @@ -97,4 +97,21 @@ describe("", () => { payload: "d2" }); }); + + test("user can change spline settings", async () => { + const state = mockAppState({ + datasetNames: ["d1", "d2"], + selectedDataset: "d1", + datasetSettings: {"d1": mockDatasetSettings()} + }); + const dispatch = jest.fn(); + const {container} = render( + + + + + ); + + expect(container.textContent).toContain("Spline options") + }); }); diff --git a/test/components/SplineOptions.test.tsx b/test/components/SplineOptions.test.tsx new file mode 100644 index 0000000..f123bde --- /dev/null +++ b/test/components/SplineOptions.test.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import {render, screen} from "@testing-library/react"; +import { + mockAppState, + mockDatasetSettings +} from "../mocks"; +import { + RootDispatchContext, + RootContext, + ActionType +} from "../../src/RootContext"; +import {userEvent} from "@testing-library/user-event"; +import SplineOptions from "../../src/components/SplineOptions"; + +describe("", () => { + + test("can change method", async () => { + const dispatch = jest.fn(); + const state = mockAppState({ + selectedDataset: "d1", + datasetSettings: { + "d1": mockDatasetSettings() + } + }); + render( + + + + + ); + + const select = screen.getByRole("listbox") as HTMLSelectElement; + expect(select.value).toBe("auto"); + expect(select.item(1)!!.value).toBe("gam"); + expect(select.item(2)!!.value).toBe("loess"); + await userEvent.selectOptions(select, "gam"); + + expect(dispatch.mock.calls[0][0]).toEqual({ + type: ActionType.SET_SPLINE_OPTIONS, + payload: {method: "gam"} + }); + }); + + test("can change span", async () => { + const dispatch = jest.fn(); + const state = mockAppState({ + selectedDataset: "d1", + datasetSettings: { + "d1": mockDatasetSettings() + } + }); + render( + + + + + ); + + const span = screen.getAllByRole("spinbutton")[0] as HTMLInputElement; + expect(span.value).toBe("0.75"); + + await userEvent.clear(span); + + expect(dispatch.mock.calls[0][0]).toEqual({ + type: ActionType.SET_SPLINE_OPTIONS, + payload: {span: 0} + }); + }); + + test("can change k", async () => { + const dispatch = jest.fn(); + const state = mockAppState({ + selectedDataset: "d1", + datasetSettings: { + "d1": mockDatasetSettings() + } + }); + render( + + + + + ); + + const k = screen.getAllByRole("spinbutton")[1] as HTMLSelectElement; + expect(k.value).toBe("10"); + await userEvent.clear(k); + + expect(dispatch.mock.calls[0][0]).toEqual({ + type: ActionType.SET_SPLINE_OPTIONS, + payload: {k: 5} + }); + }); +}); diff --git a/test/integration/dataService.itest.ts b/test/integration/dataService.itest.ts index 9e357c3..3582926 100644 --- a/test/integration/dataService.itest.ts +++ b/test/integration/dataService.itest.ts @@ -15,7 +15,7 @@ describe("DataService", () => { const formData = await getFormData("testpopulation.csv"); const res = await sut.uploadDataset(formData) as GenericResponse; expect(res.data).toEqual("testpopulation"); - expect(dispatch.mock.calls.length).toBe(0); + expect(dispatch.mock.calls.length).toBe(1); }); test("it can fetch root", async () => { @@ -30,8 +30,8 @@ describe("DataService", () => { const sut = dataService("en", dispatch); const res = await sut.getDatasetNames() as GenericResponse; expect(res.data).toEqual(["testpopulation"]); - expect(dispatch.mock.calls[0][0].type).toBe(ActionType.DATASET_NAMES_FETCHED); - expect(dispatch.mock.calls[0][0].payload).toEqual(["testpopulation"]); + expect(dispatch.mock.calls[1][0].type).toBe(ActionType.DATASET_NAMES_FETCHED); + expect(dispatch.mock.calls[1][0].payload).toEqual(["testpopulation"]); }); test("it can fetch dataset metadata", async () => { @@ -52,44 +52,58 @@ describe("DataService", () => { xcol: "day" } expect(res.data).toEqual(expectedPayload); - expect(dispatch.mock.calls[0][0].type).toBe(ActionType.DATASET_METADATA_FETCHED); - expect(dispatch.mock.calls[0][0].payload).toEqual(expectedPayload); + expect(dispatch.mock.calls[1][0].type).toBe(ActionType.DATASET_METADATA_FETCHED); + expect(dispatch.mock.calls[1][0].payload).toEqual(expectedPayload); }); - test("it can fetch dataset metadata", async () => { + test("it can fetch data series", async () => { const dispatch = jest.fn(); const sut = dataService("en", dispatch); - const res = await sut.getDataSeries("testpopulation", "ab_units", "", [], "natural") as GenericResponse; + const res = await sut.getDataSeries("testpopulation", "ab_units", "", [], "natural", + { + method: "auto", + span: 0.75, + k: 10 + }) as GenericResponse; expect(res.data!![0].name).toBe("all"); expect(res.data!![0].raw.x).toEqual([1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4]); - expect(dispatch.mock.calls.length).toBe(0); + expect(dispatch.mock.calls.length).toBe(1); }); - test("it can fetch dataset metadata with facet definition", async () => { + test("it can fetch data series with facet definition", async () => { const dispatch = jest.fn(); const sut = dataService("en", dispatch); - const res = await sut.getDataSeries("testpopulation", "ab_units", "sex:F", [], "natural") as GenericResponse; + const res = await sut.getDataSeries("testpopulation", "ab_units", "sex:F", [], "natural", + { + method: "auto", + span: 0.75, + k: 10 + }) as GenericResponse; expect(res.data!!.length).toBe(1); expect(res.data!![0].name).toBe("sex:F"); expect(res.data!![0].raw.x).toEqual([1, 2, 1, 2, 1, 2, 1, 2]); - expect(dispatch.mock.calls.length).toBe(0); + expect(dispatch.mock.calls.length).toBe(1); }); - test("it can fetch dataset metadata with facet definition and trace", async () => { + test("it can fetch data series with facet definition and trace", async () => { const dispatch = jest.fn(); const sut = dataService("en", dispatch); const res = await sut.getDataSeries("testpopulation", "ab_units", "sex:F", [{ name: "age", display: "trace", levels: ["0-5"] - }], "natural") as GenericResponse; + }], "natural", { + method: "auto", + span: 0.75, + k: 10 + }) as GenericResponse; expect(res.data!!.length).toBe(2); expect(res.data!![0].name).toBe("0-5"); expect(res.data!![0].raw.x).toEqual([1, 2, 1, 2]); expect(res.data!![1].name).toBe("5+"); expect(res.data!![1].raw.x).toEqual([1, 2, 1, 2]); - expect(dispatch.mock.calls.length).toBe(0); + expect(dispatch.mock.calls.length).toBe(1); }); }); diff --git a/test/mocks.ts b/test/mocks.ts index a993586..8cf9379 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -3,7 +3,7 @@ import MockAdapter from "axios-mock-adapter"; import { AppState, ResponseSuccess, - CovariateSettings, DatasetSettings + CovariateSettings, DatasetSettings, SplineSettings } from "../src/types"; import { DataSeries, @@ -39,10 +39,20 @@ export function mockDatasetMetadata(datasetMetadata: Partial = } } +export function mockSplineSettings(settings: Partial = {}): SplineSettings { + return { + method: "auto", + span: 0.75, + k: 10, + ...settings + } +} + export function mockDatasetSettings(settings: Partial = {}): DatasetSettings { return { covariateSettings: [], scale: "natural", + splineSettings: mockSplineSettings(), ...settings } } diff --git a/test/rootReducer.test.ts b/test/rootReducer.test.ts index ae2569d..f9df56d 100644 --- a/test/rootReducer.test.ts +++ b/test/rootReducer.test.ts @@ -109,7 +109,6 @@ describe("rootReducer", () => { expect(newState.datasetSettings["d1"].covariateSettings.length).toBe(0); }); - it("should select scale on SELECT_SCALE", () => { const state = mockAppState({ selectedDataset: "d1", @@ -122,4 +121,31 @@ describe("rootReducer", () => { {type: ActionType.SELECT_SCALE, payload: "log"}); expect(newState.datasetSettings["d1"].scale).toBe("log"); }); + + it("should clear all errors on CLEAR_ALL_ERRORS", () => { + const state = mockAppState({ + genericErrors: [mockError("1"), mockError("2")] + }); + const newState = rootReducer(state, + {type: ActionType.CLEAR_ALL_ERRORS, payload: null}); + expect(newState.genericErrors.length).toBe(0); + }); + + it("should set spline options on SET_SPLINE_OPTIONS", () => { + const state = mockAppState({selectedDataset: "d1", + datasetSettings: { + "d1": mockDatasetSettings() + }}); + let newState = rootReducer(state, + {type: ActionType.SET_SPLINE_OPTIONS, payload: { method: "gam"}}); + expect(newState.datasetSettings["d1"].splineSettings.method).toBe("gam"); + + newState = rootReducer(state, + {type: ActionType.SET_SPLINE_OPTIONS, payload: { span: 0.1}}); + expect(newState.datasetSettings["d1"].splineSettings.span).toBe(0.1); + + newState = rootReducer(state, + {type: ActionType.SET_SPLINE_OPTIONS, payload: { k: 30}}); + expect(newState.datasetSettings["d1"].splineSettings.k).toBe(30); + }); }); diff --git a/test/services/apiService.test.ts b/test/services/apiService.test.ts index c95cb8e..f87437f 100644 --- a/test/services/apiService.test.ts +++ b/test/services/apiService.test.ts @@ -33,6 +33,29 @@ describe("ApiService", () => { .toBe("some error message"); }); + it("clears all before making a call", async () => { + + mockAxios.onGet(`/`) + .reply(200, mockSuccess(true)); + + mockAxios.onPost(`/`) + .reply(200, mockSuccess(true)); + + const dispatch = jest.fn(); + + await api(rootState.language, dispatch as any) + .get("/"); + + expect(dispatch.mock.calls.length).toBe(1); + expect(dispatch.mock.calls[0][0].type).toBe(ActionType.CLEAR_ALL_ERRORS); + + await api(rootState.language, dispatch as any) + .postAndReturn("/"); + + expect(dispatch.mock.calls.length).toBe(2); + expect(dispatch.mock.calls[1][0].type).toBe(ActionType.CLEAR_ALL_ERRORS); + }); + it("dispatches the the first error message by default", async () => { mockAxios.onGet(`/unusual/`) @@ -46,9 +69,9 @@ describe("ApiService", () => { expect((console.warn as jest.Mock).mock.calls[0][0]) .toBe("No error handler registered for request /unusual/."); - expect(dispatch.mock.calls.length).toBe(1); - expect(dispatch.mock.calls[0][0].type).toBe(ActionType.ERROR_ADDED); - expect(dispatch.mock.calls[0][0].payload).toStrictEqual(mockError("some error message")); + expect(dispatch.mock.calls.length).toBe(2); + expect(dispatch.mock.calls[1][0].type).toBe(ActionType.ERROR_ADDED); + expect(dispatch.mock.calls[1][0].payload).toStrictEqual(mockError("some error message")); }); it("dispatches an extra SESSION_EXPIRED error on 404", async () => { @@ -61,11 +84,11 @@ describe("ApiService", () => { await api(rootState.language, dispatch as any) .get("/bad/"); - expect(dispatch.mock.calls.length).toBe(2); - expect(dispatch.mock.calls[0][0].type).toBe(ActionType.ERROR_ADDED); - expect(dispatch.mock.calls[0][0].payload).toStrictEqual(mockError("some error message")); + expect(dispatch.mock.calls.length).toBe(3); expect(dispatch.mock.calls[1][0].type).toBe(ActionType.ERROR_ADDED); - expect(dispatch.mock.calls[1][0].payload).toStrictEqual(mockError("Your session may have expired.", "SESSION_EXPIRED")) + expect(dispatch.mock.calls[1][0].payload).toStrictEqual(mockError("some error message")); + expect(dispatch.mock.calls[2][0].type).toBe(ActionType.ERROR_ADDED); + expect(dispatch.mock.calls[2][0].payload).toStrictEqual(mockError("Your session may have expired.", "SESSION_EXPIRED")) }); it("if no first error message, dispatches a default error message to errors module by default", async () => { @@ -86,9 +109,9 @@ describe("ApiService", () => { expect((console.warn as jest.Mock).mock.calls[0][0]) .toBe("No error handler registered for request /unusual/."); - expect(dispatch.mock.calls.length).toBe(1); - expect(dispatch.mock.calls[0][0].type).toBe(ActionType.ERROR_ADDED); - expect(dispatch.mock.calls[0][0].payload).toStrictEqual({ + expect(dispatch.mock.calls.length).toBe(2); + expect(dispatch.mock.calls[1][0].type).toBe(ActionType.ERROR_ADDED); + expect(dispatch.mock.calls[1][0].payload).toStrictEqual({ error: "MALFORMED_RESPONSE", detail: "API response failed but did not contain any error information. If error persists, please contact support.", }); @@ -249,9 +272,9 @@ describe("ApiService", () => { await api(rootState.language, dispatch as any) .get("/datasets/"); - expect(dispatch.mock.calls.length).toBe(1); - expect(dispatch.mock.calls[0][0].type).toBe(ActionType.ERROR_ADDED); - expect(dispatch.mock.calls[0][0].payload).toStrictEqual({ + expect(dispatch.mock.calls.length).toBe(2); + expect(dispatch.mock.calls[1][0].type).toBe(ActionType.ERROR_ADDED); + expect(dispatch.mock.calls[1][0].payload).toStrictEqual({ error: "MALFORMED_RESPONSE", detail: "Could not parse API response. If error persists, please contact support." }); diff --git a/test/services/plotUtils.test.ts b/test/services/utils.test.ts similarity index 84% rename from test/services/plotUtils.test.ts rename to test/services/utils.test.ts index 350d16f..388397e 100644 --- a/test/services/plotUtils.test.ts +++ b/test/services/utils.test.ts @@ -1,4 +1,4 @@ -import {calculateFacets, toFilename} from "../../src/services/plotUtils"; +import {between, calculateFacets, toFilename} from "../../src/services/utils"; import {Variable} from "../../src/generated"; describe("plotUtils", () => { @@ -36,4 +36,11 @@ describe("plotUtils", () => { expect(toFilename("ab_units")).toBe("ab_units") expect(toFilename("ABunits")).toBe("abunits") }) + + + it("can bound number by min and max", () => { + expect(between(1.1, 0, 1)).toBe(1); + expect(between(-0.1, 0, 1)).toBe(0); + expect(between(0.1, 0, 1)).toBe(0.1); + }) });