Skip to content

Commit

Permalink
Merge pull request #8 from seroanalytics/session
Browse files Browse the repository at this point in the history
handle expired sessions
  • Loading branch information
hillalex authored Sep 6, 2024
2 parents c635756 + 66d744c commit 7f763ef
Show file tree
Hide file tree
Showing 15 changed files with 216 additions and 68 deletions.
2 changes: 1 addition & 1 deletion src/RootContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const initialState: AppState = {
selectedDataset: "",
datasetSettings: {},
uploadError: null,
genericError: null,
genericErrors: [],
language: "en"
}

Expand Down
8 changes: 5 additions & 3 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,18 @@ export default function App() {

useEffect(() => {
setInterval(() => {
dataService("en", () => {})
dataService("en", () => {
})
.refreshSession()
}, 60*1000);
}, 60 * 1000);
}, []);

return <RootContext.Provider value={state}>
<RootDispatchContext.Provider value={dispatch}>
<TopNav theme={theme as string}
setTheme={setTheme as (newState: string) => void}></TopNav>
<AppError/>
{state.genericErrors.map((e, index) => <AppError error={e}
key={"error" + index}/>)}
<Container fluid>
{!state.selectedDataset && <ChooseOrUploadDataset/>}
{state.selectedDataset && <ExploreDataset/>}
Expand Down
24 changes: 14 additions & 10 deletions src/components/AppError.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import {Alert, Button} from "react-bootstrap";
import React, {useContext} from "react";
import {ActionType, RootContext, RootDispatchContext} from "../RootContext";
import {ActionType, RootDispatchContext} from "../RootContext";
import {ErrorDetail} from "../generated";

export default function AppError() {
const state = useContext(RootContext);
interface Props {
error: ErrorDetail
}

export default function AppError({error}: Props) {
const dispatch = useContext(RootDispatchContext);
const message = error.detail ?? `API returned an error: ${error.error}`;
const remove = () => {
dispatch({type: ActionType.ERROR_DISMISSED, payload: null});
dispatch({type: ActionType.ERROR_DISMISSED, payload: error});
}
if (state.genericError) {
return <Alert variant={"danger"} className={"rounded-0 border-0"}>
<Button variant={"close"} role={"close"} onClick={remove} className={"mx-2 float-end"}></Button>
{state.genericError.detail ?? `API returned an error: ${state.genericError.error}`}
</Alert>
} else return null
return <Alert variant={"danger"} className={"rounded-0 border-0 mb-1"}>
<Button variant={"close"} role={"close"} onClick={remove}
className={"mx-2 float-end"}></Button>
{message} {error.error === "SESSION_EXPIRED" && <a href={"/"}>Re-upload your data to continue.</a>}
</Alert>
}
5 changes: 2 additions & 3 deletions src/components/ChooseOrUploadDataset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,8 @@ export function ChooseOrUploadDataset() {
<Form.Text muted>File must be in CSV format. Required
columns: biomarker, value, day. Files you upload are
only accessible to you and
will persist for one hour or until you close your
browser,
whichever is longer.</Form.Text>
will be deleted when you close your
browser.</Form.Text>
<div className={"d-block mt-2"}>
<button className="btn btn-link p-0"
onClick={toggleShowOptions}>Advanced options
Expand Down
6 changes: 6 additions & 0 deletions src/components/LinePlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export default function LinePlot({
if (result && result.data) {
setSeries(result.data)
}

else {
setSeries(null)
}
}
fetchData();
}, [state.language, dispatch, state.selectedDataset, biomarker, facetDefinition, covariateSettings, scale]);
Expand Down Expand Up @@ -81,6 +85,8 @@ export default function LinePlot({
showlegend: false,
marker: {color: colors[index], opacity: 0.5}
}]))
} else {
series = []
}

return <Plot
Expand Down
2 changes: 1 addition & 1 deletion src/components/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default function SideBar() {

return <Col xs="3" className="pt-3 border-1 border-end border-secondary"
data-testid="sidebar">
<Form method="post">
<Form>
<fieldset>
<ChooseDataset selectedDataset={state.selectedDataset}
selectDataset={selectDataset}/>
Expand Down
10 changes: 8 additions & 2 deletions src/reducers/rootReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ export const rootReducer = (state: AppState, action: RootAction): AppState => {
console.log(action.type);
switch (action.type) {
case ActionType.ERROR_ADDED:
return {...state, genericError: action.payload}
return {
...state,
genericErrors: [...state.genericErrors, action.payload]
}
case ActionType.ERROR_DISMISSED:
return {...state, genericError: null}
return {
...state,
genericErrors: state.genericErrors.filter(e => e.detail !== action.payload.detail || e.error !== action.payload.error)
}
case ActionType.UPLOAD_ERROR_ADDED:
return {...state, uploadError: action.payload}
case ActionType.UPLOAD_ERROR_DISMISSED:
Expand Down
13 changes: 8 additions & 5 deletions src/services/apiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ export class APIService implements API<ActionType> {
return failure.errors[0];
};

static createError(detail: string) {
static createError(detail: string| null, error: string = "MALFORMED_RESPONSE") {
return {
error: "MALFORMED_RESPONSE",
error: error,
detail: detail
}
}
Expand Down Expand Up @@ -106,11 +106,14 @@ export class APIService implements API<ActionType> {

private _handleError = (e: AxiosError) => {
console.log(e.response && (e.response.data || e));
if (this._ignoreErrors) {
return

if (!this._ignoreErrors) {
this._handleDispatchError(e.response && e.response.data)
}

this._handleDispatchError(e.response && e.response.data)
if (e.response && e.response.status === 404) {
this._dispatchError(APIService.createError("Your session may have expired.", "SESSION_EXPIRED"));
}
};

private _dispatchError = (error: ErrorDetail) => {
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@ export interface AppState {
selectedDataset: string
datasetSettings: Dict<DatasetSettings>
uploadError: ErrorDetail | null
genericError: ErrorDetail | null
genericErrors: ErrorDetail[]
language: string
}
46 changes: 46 additions & 0 deletions test/components/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from "react";
import {render, screen, waitFor} from "@testing-library/react";
import {
mockAppState,
mockAxios,
mockDatasetMetadata,
mockError, mockFailure, mockSeriesData,
mockSuccess
} from "../mocks";
import {RootContext, RootDispatchContext} from "../../src/RootContext";
import App from "../../src/components/App";
import {userEvent} from "@testing-library/user-event";

describe("<App />", () => {

beforeEach(() => {
mockAxios.reset();
});

test("should display any generic errors", async () => {
mockAxios.onGet("/datasets/")
.reply(404, mockFailure("bad"));

render(<App/>);
const errors = await screen.findAllByRole("alert");
expect(errors.length).toBe(2);
});

test("should fetch dataset metadata if dataset selected", async () => {
mockAxios.onGet("/datasets/")
.reply(200, mockSuccess(["d1"]));

mockAxios.onGet("/dataset/d1/")
.reply(200, mockSuccess(mockDatasetMetadata()));

mockAxios.onGet("/dataset/d1/trace/ab/?scale=natural")
.reply(200, mockSuccess(mockSeriesData()));

render(<App/>);

const go = await screen.findByText("Go");
await userEvent.click(go);

await screen.findByText("Detected biomarkers");
});
});
64 changes: 30 additions & 34 deletions test/components/AppError.test.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,53 @@
import React from "react";
import {fireEvent, render, screen} from "@testing-library/react";
import AppError from "../../src/components/AppError";
import {mockAppState, mockError} from "../mocks";
import {ActionType, RootContext, RootDispatchContext} from "../../src/RootContext";
import {mockError} from "../mocks";
import {ActionType, RootDispatchContext} from "../../src/RootContext";

describe("<AppError />", () => {

test("should be null if no error present", () => {
const state = mockAppState();
const dispatch = jest.fn();
const {container} = render(<RootContext.Provider value={state}>
<RootDispatchContext.Provider
value={dispatch}><AppError/>
</RootDispatchContext.Provider>
</RootContext.Provider>);
expect(container.firstChild).toBe(null);
});

test("should display error detail if present", () => {
const state = mockAppState({genericError: mockError("custom message")});
const dispatch = jest.fn();
render(<RootContext.Provider value={state}>
<RootDispatchContext.Provider value={dispatch}>
<AppError/>
</RootDispatchContext.Provider>
</RootContext.Provider>);
const error = mockError("custom message");
const dispatch = jest.fn();
render(<RootDispatchContext.Provider value={dispatch}>
<AppError error={error}/>
</RootDispatchContext.Provider>);
const alert = screen.getByRole("alert");
expect(alert.lastChild?.textContent).toBe("custom message");
expect(alert.textContent).toBe("custom message ");
});

test("should display error type if no detail present", () => {
const state = mockAppState({genericError: mockError(null as any)});
const error = mockError(null as any);
const dispatch = jest.fn();
render(<RootDispatchContext.Provider value={dispatch}>
<AppError error={error}/>
</RootDispatchContext.Provider>);
const alert = screen.getByRole("alert");
expect(alert.textContent).toBe("API returned an error: OTHER_ERROR ");
});

test("should display home link if type is SESSION_EXPIRED", () => {
const error = mockError("Session expired.", "SESSION_EXPIRED");
const dispatch = jest.fn();
render(<RootContext.Provider value={state}>
<RootDispatchContext.Provider
value={dispatch}><AppError/>
</RootDispatchContext.Provider>
</RootContext.Provider>);
render(<RootDispatchContext.Provider value={dispatch}>
<AppError error={error}/>
</RootDispatchContext.Provider>);
const alert = screen.getByRole("alert");
expect(alert.lastChild?.textContent).toBe("API returned an error: OTHER_ERROR");
expect(alert.textContent).toBe("Session expired. Re-upload your data to continue.");
const link = screen.getByRole("link") as HTMLAnchorElement;
expect(link.href).toBe("http://localhost/");
});

test("should dispatch ERROR_DISMISSED action when closed", () => {
const state = mockAppState({genericError: mockError("custom message")});
const error = mockError("custom message");
const dispatch = jest.fn();
render(<RootContext.Provider value={state}>
<RootDispatchContext.Provider
value={dispatch}><AppError/>
</RootDispatchContext.Provider>
</RootContext.Provider>);
render(<RootDispatchContext.Provider value={dispatch}>
<AppError error={error}/>
</RootDispatchContext.Provider>);
const closeButton = screen.getByRole("close");
fireEvent.click(closeButton);
expect(dispatch.mock.calls.length).toBe(1);
expect(dispatch.mock.calls[0][0].type).toBe(ActionType.ERROR_DISMISSED);
expect(dispatch.mock.calls[0][0].payload).toEqual(error);
});
});
64 changes: 63 additions & 1 deletion test/components/LinePlot.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
mockAppState,
mockAxios, mockDatasetMetadata,
mockDatasetSettings,
mockDatasetSettings, mockFailure,
mockSuccess
} from "../mocks";
import {render, waitFor} from "@testing-library/react";
Expand Down Expand Up @@ -183,4 +183,66 @@ describe("<LinePlot />", () => {
y: [3, 4]
}])
});

test("clears plot data if request to API fails", async () => {
mockAxios.onGet("/dataset/d1/trace/ab/?scale=natural")
.reply(200, mockSuccess<DataSeries>([{
name: "all",
model: {
x: [1.1, 2.2],
y: [3.3, 4.4]
},
raw: {
x: [1, 2],
y: [3, 4]
}
}]));

mockAxios.onGet("/dataset/d1/trace/ab/?filter=sex%3AF&scale=natural")
.reply(404, mockFailure("bad"));

const dispatch = jest.fn();
const state = mockAppState({
selectedDataset: "d1",
datasetMetadata: mockDatasetMetadata(),
datasetSettings: {
"d1": mockDatasetSettings()
}
});
const {rerender} = render(<RootContext.Provider value={state}>
<RootDispatchContext.Provider value={dispatch}>
<LinePlot biomarker={"ab"}
facetLevels={[]}
facetVariables={[]}/>
</RootDispatchContext.Provider>
</RootContext.Provider>);

await waitFor(() => expect(mockAxios.history.get.length)
.toBe(1));

await waitFor(() => expect((Plot as Mock))
.toBeCalledTimes(2));

let plot = Plot as Mock
expect(plot.mock.calls[1][0].data.length).toBeGreaterThan(0);

rerender(<RootContext.Provider value={state}>
<RootDispatchContext.Provider value={dispatch}>
<LinePlot biomarker={"ab"}
facetLevels={["F"]}
facetVariables={["sex"]}/>
</RootDispatchContext.Provider>
</RootContext.Provider>);

await waitFor(() => expect(mockAxios.history.get.length)
.toBe(2));

await waitFor(() => expect((Plot as Mock))
.toBeCalledTimes(4));

plot = Plot as Mock
expect(plot.mock.calls[2][0].data.length).toBe(2);
expect(plot.mock.calls[3][0].data.length).toBe(0);
});

});
6 changes: 3 additions & 3 deletions test/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function mockAppState(state: Partial<AppState> = {}): AppState {
selectedDataset: "",
datasetSettings: {},
uploadError: null,
genericError: null,
genericErrors: [],
language: "en",
...state
}
Expand Down Expand Up @@ -103,8 +103,8 @@ export const mockFailure = (errorMessage: string): ResponseFailure => {
}
};

export const mockError = (errorMessage: string): ErrorDetail => {
return {error: "OTHER_ERROR", detail: errorMessage};
export const mockError = (detail: string, error: string = "OTHER_ERROR"): ErrorDetail => {
return {error: error, detail: detail};
};

export const mockAxios = new MockAdapter(axios);
Loading

0 comments on commit 7f763ef

Please sign in to comment.