Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,31 +1,28 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, expect } from 'vitest';
import { render } from 'vitest-browser-react';

import LapisUnreachableWrapperClient from './LapisUnreachableWrapperClient';
import { LapisUnreachableWrapperClient } from './LapisUnreachableWrapperClient';
import { DUMMY_LAPIS_URL } from '../../routeMocker';
import { it } from '../../test-extend';

const queryClient = new QueryClient();

describe('LapisUnreachableWrapperClient', () => {
it('displays children when LAPIS is reachable', async ({ routeMockers: { lapis } }) => {
lapis.mockPostAggregated({}, { data: [{ count: 100 }] });

const { getByText } = render(
<LapisUnreachableWrapperClient lapisUrl={DUMMY_LAPIS_URL}>
<div>Content is visible</div>
</LapisUnreachableWrapperClient>,
);
const content = 'Content is visible';

const { getByText } = renderWithContent(content);

await expect.element(getByText('Content is visible')).toBeVisible();
await expect.element(getByText(content)).toBeVisible();
});

it('displays error message when LAPIS is unreachable', async ({ routeMockers: { lapis } }) => {
lapis.mockLapisDown();

const { getByText } = render(
<LapisUnreachableWrapperClient lapisUrl={DUMMY_LAPIS_URL}>
<div>Content is visible</div>
</LapisUnreachableWrapperClient>,
);
const { getByText } = renderWithContent('Content is visible');

await expect.element(getByText('Data Source Unreachable')).toBeVisible();
await expect.element(getByText('Unable to connect to the data source')).toBeVisible();
Expand All @@ -34,36 +31,36 @@ describe('LapisUnreachableWrapperClient', () => {
it('does not display children when LAPIS is unreachable', async ({ routeMockers: { lapis } }) => {
lapis.mockLapisDown();

const { getByText } = render(
<LapisUnreachableWrapperClient lapisUrl={DUMMY_LAPIS_URL}>
<div>Content should not be visible</div>
</LapisUnreachableWrapperClient>,
);
const content = 'Content is visible';

await expect.poll(() => getByText('Content should not be visible').query()).not.toBeInTheDocument();
const { getByText } = renderWithContent(content);

await expect.poll(() => getByText(content).query()).not.toBeInTheDocument();
});

it('displays error when LAPIS returns invalid response', async ({ routeMockers: { lapis } }) => {
lapis.mockPostAggregated({}, { data: [] }, 200);

const { getByText } = render(
<LapisUnreachableWrapperClient lapisUrl={DUMMY_LAPIS_URL}>
<div>Content is visible</div>
</LapisUnreachableWrapperClient>,
);
const { getByText } = renderWithContent('Content is visible');

await expect.element(getByText('Data Source Unreachable')).toBeVisible();
});

it('displays error when LAPIS returns 500', async ({ routeMockers: { lapis } }) => {
lapis.mockPostAggregated({}, { data: [{ count: 100 }] }, 500);

const { getByText } = render(
<LapisUnreachableWrapperClient lapisUrl={DUMMY_LAPIS_URL}>
<div>Content is visible</div>
</LapisUnreachableWrapperClient>,
);
const { getByText } = renderWithContent('Content is visible');

await expect.element(getByText('Data Source Unreachable')).toBeVisible();
});
});

function renderWithContent(content: string) {
return render(
<QueryClientProvider client={queryClient}>
<LapisUnreachableWrapperClient lapisUrl={DUMMY_LAPIS_URL}>
<div>{content}</div>
</LapisUnreachableWrapperClient>
</QueryClientProvider>,
);
}
7 changes: 1 addition & 6 deletions website/src/components/LapisUnreachableWrapperClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import { useQuery } from '@tanstack/react-query';
import { type FC, type ReactNode } from 'react';

import { checkLapisHealth } from '../lapis/checkLapisHealth';
import { withQueryProvider } from './subscriptions/backendApi/withQueryProvider';

type LapisUnreachableWrapperClientProps = {
lapisUrl: string;
children: ReactNode;
};

const LapisUnreachableWrapperClientInner: FC<LapisUnreachableWrapperClientProps> = ({ lapisUrl, children }) => {
export const LapisUnreachableWrapperClient: FC<LapisUnreachableWrapperClientProps> = ({ lapisUrl, children }) => {
const { data: isReachable, isLoading } = useQuery({
queryKey: ['lapis-reachable', lapisUrl],
queryFn: () => checkLapisHealth(lapisUrl),
Expand Down Expand Up @@ -37,7 +36,3 @@ const LapisUnreachableWrapperClientInner: FC<LapisUnreachableWrapperClientProps>

return <>{children}</>;
};

const LapisUnreachableWrapperClient = withQueryProvider(LapisUnreachableWrapperClientInner);

export default LapisUnreachableWrapperClient;
2 changes: 2 additions & 0 deletions website/src/components/genspectrum/GsSequencesByLocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const GsSequencesByLocation: FC<GsSequencesByLocationProps> = ({
<ComponentWrapper title={title} height={height}>
{mapData === undefined ? (
<gs-sequences-by-location
key={mapName} // Force remount when mapName changes - otherwise React seems to set props to null that were set before which is invalid for the web component
lapisLocationField={lapisLocationField}
lapisFilter={JSON.stringify(lapisFilter)}
pageSize={pageSize ?? defaultTablePageSize}
Expand All @@ -37,6 +38,7 @@ export const GsSequencesByLocation: FC<GsSequencesByLocationProps> = ({
/>
) : (
<gs-sequences-by-location
key={mapName}
lapisLocationField={lapisLocationField}
lapisFilter={JSON.stringify(lapisFilter)}
pageSize={pageSize ?? defaultTablePageSize}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { act } from '@testing-library/react';
import React, { useState } from 'react';
import { describe, expect } from 'vitest';
import { render } from 'vitest-browser-react';
import { render, renderHook, type RenderResult } from 'vitest-browser-react';

import { ApplyFilterButton } from './ApplyFilterButton';
import { it } from '../../../test-extend';
Expand Down Expand Up @@ -29,69 +30,91 @@ const urlTooLongMessage = /URL is too long/i;
describe('ApplyFilterButton', () => {
const handler = new DummyPageStateHandler();

it('should render enabled link for short URL', () => {
it('should render enabled link for short URL', async () => {
const shortState: DummyPageState = { data: 'short' };

const { getByRole, container } = render(
<ApplyFilterButton pageStateHandler={handler} newPageState={shortState} />,
const { result: state } = renderHook(() => useState<DummyPageState>({ data: 'initial' }));

const { getByRole } = render(
<ApplyFilterButton pageStateHandler={handler} newPageState={shortState} setPageState={state.current[1]} />,
);

const link = getByRole('button');
expect(link.element()).toBeInTheDocument();
expect(link.element()).toHaveAttribute('href', '/test?data=short');
expect(container.textContent).not.toMatch(urlTooLongMessage);
await clickApply(getByRole);

expect(state.current[0].data).toEqual('short');
});

it('should render disabled span and error message for long URL', () => {
it('should render disabled span and error message for long URL', async () => {
const longData = 'x'.repeat(2000);
const longState: DummyPageState = { data: longData };

const { result: state } = renderHook(() => useState<DummyPageState>({ data: 'initial' }));

const { getByRole, container } = render(
<ApplyFilterButton pageStateHandler={handler} newPageState={longState} />,
<ApplyFilterButton pageStateHandler={handler} newPageState={longState} setPageState={state.current[1]} />,
);

const button = getByRole('button');
expect(button.element()).not.toHaveAttribute('href');
expect(container.textContent).toMatch(urlTooLongMessage);
expect(getByRole('button', { name: 'Apply filters' }).element()).not.toHaveAttribute('onClick');

await clickApply(getByRole);
expect(state.current[0].data).toEqual('initial');
});

it('should update when state changes from short to long', () => {
it('should update when state changes from short to long', async () => {
const shortState: DummyPageState = { data: 'short' };

const { result: state } = renderHook(() => useState<DummyPageState>({ data: 'initial' }));

const { getByRole, container, rerender } = render(
<ApplyFilterButton pageStateHandler={handler} newPageState={shortState} />,
<ApplyFilterButton pageStateHandler={handler} newPageState={shortState} setPageState={state.current[1]} />,
);

const button = getByRole('button');
expect(button.element()).toHaveAttribute('href', '/test?data=short');
await clickApply(getByRole);
expect(state.current[0].data).toEqual('short');
expect(container.textContent).not.toMatch(urlTooLongMessage);

const longData = 'x'.repeat(2000);
const longState: DummyPageState = { data: longData };
rerender(<ApplyFilterButton pageStateHandler={handler} newPageState={longState} />);
rerender(
<ApplyFilterButton pageStateHandler={handler} newPageState={longState} setPageState={state.current[1]} />,
);

const updatedButton = getByRole('button');
expect(updatedButton.element()).not.toHaveAttribute('href');
expect(container.textContent).toMatch(urlTooLongMessage);
expect(getByRole('button', { name: 'Apply filters' }).element()).not.toHaveAttribute('onClick');

await clickApply(getByRole);
expect(state.current[0].data).toEqual('short');
});

it('should update when state changes from long to short', () => {
it('should update when state changes from long to short', async () => {
const longData = 'x'.repeat(2000);
const longState: DummyPageState = { data: longData };

const { result: state } = renderHook(() => useState<DummyPageState>({ data: 'initial' }));

const { getByRole, container, rerender } = render(
<ApplyFilterButton pageStateHandler={handler} newPageState={longState} />,
<ApplyFilterButton pageStateHandler={handler} newPageState={longState} setPageState={state.current[1]} />,
);

const button = getByRole('button');
expect(button.element()).not.toHaveAttribute('href');
expect(getByRole('button', { name: 'Apply filters' }).element()).not.toHaveAttribute('onClick');
expect(container.textContent).toMatch(urlTooLongMessage);
await clickApply(getByRole);
expect(state.current[0].data).toEqual('initial');

const shortState: DummyPageState = { data: 'short' };
rerender(<ApplyFilterButton pageStateHandler={handler} newPageState={shortState} />);
rerender(
<ApplyFilterButton pageStateHandler={handler} newPageState={shortState} setPageState={state.current[1]} />,
);

const updatedButton = getByRole('button');
expect(updatedButton.element()).toHaveAttribute('href', '/test?data=short');
expect(container.textContent).not.toMatch(urlTooLongMessage);
await clickApply(getByRole);
expect(state.current[0].data).toEqual('short');
});
});

async function clickApply(getByRole: RenderResult['getByRole']) {
await act(async () => {
await getByRole('button', { name: 'Apply filters' }).click();
});
}
12 changes: 10 additions & 2 deletions website/src/components/pageStateSelectors/ApplyFilterButton.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Dispatch, SetStateAction } from 'react';

import type { WithClassName } from '../../types/WithClassName.ts';
import type { PageStateHandler } from '../../views/pageStateHandlers/PageStateHandler.ts';

Expand All @@ -8,15 +10,21 @@ const MAX_URL_LENGTH = 2000;
export function ApplyFilterButton<PageState extends object>({
pageStateHandler,
newPageState,
setPageState,
className = '',
}: WithClassName<{
pageStateHandler: PageStateHandler<PageState>;
newPageState: PageState;
setPageState: Dispatch<SetStateAction<PageState>>;
}>) {
const url = pageStateHandler.toUrl(newPageState);
const fullUrl = `${window.location.origin}${url}`;
const urlTooLong = fullUrl.length > MAX_URL_LENGTH;

const applyFilters = () => {
setPageState(newPageState);
};

return urlTooLong ? (
<>
<span role='button' className={`btn btn-primary btn-disabled ${className}`}>
Expand All @@ -30,8 +38,8 @@ export function ApplyFilterButton<PageState extends object>({
</div>
</>
) : (
<a role='button' href={url} className={`btn btn-primary ${className}`}>
<button type='button' onClick={applyFilters} className={`btn btn-primary ${className}`}>
Apply filters
</a>
</button>
);
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,38 @@
import { useMemo, useState } from 'react';
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from 'react';

import { ApplyFilterButton } from './ApplyFilterButton.tsx';
import { BaselineSelector } from './BaselineSelector.tsx';
import { SelectorHeadline } from './SelectorHeadline.tsx';
import { makeVariantFilterConfig, VariantSelector } from './VariantSelector.tsx';
import type { OrganismsConfig } from '../../config.ts';
import { Inset } from '../../styles/Inset.tsx';
import { BaseView } from '../../views/BaseView.ts';
import type { OrganismConstants } from '../../views/OrganismConstants.ts';
import type { CompareSideBySideData } from '../../views/View.ts';
import { type OrganismViewKey, Routing } from '../../views/routing.ts';
import type { compareSideBySideViewKey } from '../../views/viewKeys.ts';
import { CompareSideBySideStateHandler } from '../../views/pageStateHandlers/CompareSideBySidePageStateHandler.ts';

export function CompareSideBySidePageStateSelector({
view,
filterId,
initialPageState,
organismViewKey,
organismsConfig,
pageState,
setPageState,
enableAdvancedQueryFilter,
}: {
view: BaseView<CompareSideBySideData, OrganismConstants, CompareSideBySideStateHandler>;
filterId: number;
initialPageState: CompareSideBySideData;
organismViewKey: OrganismViewKey & `${string}.${typeof compareSideBySideViewKey}`;
organismsConfig: OrganismsConfig;
pageState: CompareSideBySideData;
setPageState: Dispatch<SetStateAction<CompareSideBySideData>>;
enableAdvancedQueryFilter: boolean;
}) {
const view = useMemo(() => new Routing(organismsConfig), [organismsConfig]).getOrganismView(organismViewKey);
const [draftPageState, setDraftPageState] = useState(pageState);
useEffect(() => setDraftPageState(pageState), [pageState]);

const variantFilterConfig = useMemo(
() => makeVariantFilterConfig(view.organismConstants),
[view.organismConstants],
);
const [pageState, setPageState] = useState(initialPageState);

const { filterOfCurrentId, currentLapisFilter } = useMemo(() => {
const filterOfCurrentId = pageState.filters.get(filterId) ?? {
const filterOfCurrentId = draftPageState.filters.get(filterId) ?? {
datasetFilter: {
locationFilters: {},
dateFilters: {},
Expand All @@ -47,7 +48,7 @@ export function CompareSideBySidePageStateSelector({
filterOfCurrentId.variantFilter,
),
};
}, [pageState, filterId, view.pageStateHandler]);
}, [draftPageState, filterId, view.pageStateHandler]);

return (
<div className='flex flex-col gap-4 p-2 shadow-lg'>
Expand All @@ -60,8 +61,8 @@ export function CompareSideBySidePageStateSelector({
lapisFilter={currentLapisFilter}
datasetFilter={filterOfCurrentId.datasetFilter}
setDatasetFilter={(newDatasetFilter) => {
setPageState((previousState) => {
const updatedFilters = new Map(initialPageState.filters);
setDraftPageState((previousState) => {
const updatedFilters = new Map(previousState.filters);
updatedFilters.set(filterId, {
...filterOfCurrentId,
datasetFilter: newDatasetFilter,
Expand All @@ -78,8 +79,8 @@ export function CompareSideBySidePageStateSelector({
<Inset className='p-2'>
<VariantSelector
onVariantFilterChange={(newVariantFilter) => {
setPageState((previousState) => {
const updatedFilters = new Map(initialPageState.filters);
setDraftPageState((previousState) => {
const updatedFilters = new Map(previousState.filters);
updatedFilters.set(filterId, {
...filterOfCurrentId,
variantFilter: newVariantFilter,
Expand All @@ -96,7 +97,11 @@ export function CompareSideBySidePageStateSelector({
</div>
</div>
<div className='flex justify-end'>
<ApplyFilterButton pageStateHandler={view.pageStateHandler} newPageState={pageState} />
<ApplyFilterButton
pageStateHandler={view.pageStateHandler}
newPageState={draftPageState}
setPageState={setPageState}
/>
</div>
</div>
);
Expand Down
Loading
Loading