Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(website): store previewed sequence ID in URL #3762

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e29f28b
preview in url
theosanderson Feb 26, 2025
3ef3064
try an e2e test
theosanderson Feb 26, 2025
0c0b34d
lint
theosanderson Feb 26, 2025
bc66c21
fix
theosanderson Feb 26, 2025
d656f67
fix maybe
theosanderson Feb 26, 2025
579de23
refactor
theosanderson Mar 13, 2025
be046f9
u
theosanderson Feb 26, 2025
e15ae83
format and cleanup
theosanderson Mar 13, 2025
9604b87
cleanup
theosanderson Mar 12, 2025
c7d3320
update
theosanderson Mar 13, 2025
c517025
try an update
theosanderson Mar 13, 2025
6b9af64
having lots of problems with dependency loops - reverting to the dupl…
theosanderson Mar 13, 2025
4ff6e3a
another version that at least works
theosanderson Mar 13, 2025
af9bf22
working wip
theosanderson Mar 13, 2025
fd75c6d
working actually wip
theosanderson Mar 13, 2025
8e30ac2
clean up
theosanderson Mar 13, 2025
dbb0c4d
Merge branch 'previewurl' of https://github.com/loculus-project/locul…
theosanderson Mar 13, 2025
a7da638
refactor
theosanderson Mar 13, 2025
6fb66e7
waka waka
theosanderson Mar 13, 2025
376db73
fixup
theosanderson Mar 13, 2025
8d37443
format
theosanderson Mar 13, 2025
b97d8a1
claude linting
theosanderson Mar 13, 2025
3b411fb
clean a bit
theosanderson Mar 13, 2025
7209598
improve
theosanderson Mar 13, 2025
d43ac7f
cleanup
theosanderson Mar 13, 2025
3642ff6
format
theosanderson Mar 13, 2025
b5b58db
Merge branch 'previewurl' of https://github.com/loculus-project/locul…
theosanderson Mar 13, 2025
04bd4a2
Remove comments from sequence-preview-url.spec.ts
theosanderson Mar 13, 2025
6da3098
Remove unnecessary type casting in useUrlParamState
theosanderson Mar 14, 2025
2fb6fc6
Fix spacing in parseUrlValue function
theosanderson Mar 14, 2025
2f8b98f
Merge branch 'main' into previewurl
theosanderson Mar 14, 2025
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
29 changes: 29 additions & 0 deletions integration-tests/tests/pages/search.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,33 @@ export class SearchPage {
async resetSearchForm() {
await this.page.getByRole('button', { name: 'reset' }).click();
}

async getSequenceRows() {
return this.page.locator('[data-testid="sequence-row"]');
}

async clickOnSequence(rowIndex = 0) {
const rows = await this.getSequenceRows();
await rows.nth(rowIndex).click();
}

async getSequencePreviewModal() {
return this.page.locator('[data-testid="sequence-preview-modal"]');
}

async getHalfScreenPreview() {
return this.page.locator('[data-testid="half-screen-preview"]');
}

async toggleHalfScreenButton() {
return this.page.locator('[data-testid="toggle-half-screen-button"]');
}

async closePreviewButton() {
return this.page.locator('[data-testid="close-preview-button"]');
}

async getUrlParams() {
return new URL(this.page.url()).searchParams;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { expect } from '@playwright/test';
import { test } from '../../fixtures/auth.fixture';
import { SearchPage } from '../../pages/search.page';

test.describe('Sequence Preview URL Parameters', () => {
let searchPage: SearchPage;

test.beforeEach(async ({ page }) => {
searchPage = new SearchPage(page);
});

test('should store the previewed sequence ID in the URL', async ({ page }) => {
await searchPage.ebolaSudan();

let urlParams = await searchPage.getUrlParams();
expect(urlParams.has('selectedSeq')).toBe(false);

await searchPage.clickOnSequence(0);

await expect(page.locator('[data-testid="sequence-preview-modal"]')).toBeVisible();

urlParams = await searchPage.getUrlParams();
expect(urlParams.has('selectedSeq')).toBe(true);
expect(urlParams.get('selectedSeq')).not.toBeNull();
const selectedSeqId = urlParams.get('selectedSeq');

await (await searchPage.closePreviewButton()).click();

urlParams = await searchPage.getUrlParams();
expect(urlParams.has('selectedSeq')).toBe(false);

const currentUrl = new URL(page.url());
currentUrl.searchParams.set('selectedSeq', selectedSeqId);
await page.goto(currentUrl.toString());

await expect(page.locator('[data-testid="sequence-preview-modal"]')).toBeVisible();
});

test('should store half-screen state in the URL', async ({ page }) => {
await searchPage.ebolaSudan();

await searchPage.clickOnSequence(0);

await expect(page.locator('[data-testid="sequence-preview-modal"]')).toBeVisible();

await (await searchPage.toggleHalfScreenButton()).click();

let urlParams = await searchPage.getUrlParams();
expect(urlParams.has('halfScreen')).toBe(true);
expect(urlParams.get('halfScreen')).toBe('true');

await expect(page.locator('[data-testid="half-screen-preview"]')).toBeVisible();

await (await searchPage.toggleHalfScreenButton()).click();

urlParams = await searchPage.getUrlParams();
expect(urlParams.has('halfScreen')).toBe(false);

await expect(page.locator('[data-testid="sequence-preview-modal"]')).toBeVisible();
});

test('should restore state from URL parameters on page load', async ({ page }) => {
await searchPage.ebolaSudan();

await searchPage.clickOnSequence(0);
await expect(page.locator('[data-testid="sequence-preview-modal"]')).toBeVisible();

const urlParams = await searchPage.getUrlParams();
const selectedSeqId = urlParams.get('selectedSeq');

await (await searchPage.toggleHalfScreenButton()).click();

await (await searchPage.closePreviewButton()).click();

const currentUrl = new URL(page.url());
currentUrl.searchParams.set('selectedSeq', selectedSeqId);
currentUrl.searchParams.set('halfScreen', 'true');
await page.goto(currentUrl.toString());

await expect(page.locator('[data-testid="half-screen-preview"]')).toBeVisible();

await page.goto('/');
await page.goBack();

await expect(page.locator('[data-testid="half-screen-preview"]')).toBeVisible();
});
});
29 changes: 23 additions & 6 deletions website/src/components/SearchPage/SearchFullUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SeqPreviewModal } from './SeqPreviewModal';
import { Table, type TableSequenceData } from './Table';
import useQueryAsState from './useQueryAsState.js';
import { getLapisUrl } from '../../config.ts';
import useUrlParamState from '../../hooks/useUrlParamState';
import { lapisClientHooks } from '../../services/serviceHooks.ts';
import { DATA_USE_TERMS_FIELD, pageSize } from '../../settings';
import type { Group } from '../../types/backend.ts';
Expand Down Expand Up @@ -94,10 +95,25 @@ export const InnerSearchFullUI = ({
return consolidateGroupedFields(metadataSchemaWithExpandedRanges);
}, [metadataSchema]);

const [previewedSeqId, setPreviewedSeqId] = useState<string | null>(null);
const [previewHalfScreen, setPreviewHalfScreen] = useState(false);
const [state, setState] = useQueryAsState(initialQueryDict);

const [previewedSeqId, setPreviewedSeqId] = useUrlParamState<string | null>(
'selectedSeq',
state,
null,
setState,
'nullable-string',
(value) => !value,
);
const [previewHalfScreen, setPreviewHalfScreen] = useUrlParamState(
'halfScreen',
state,
false,
setState,
'boolean',
(value) => !value,
);

const searchVisibilities = useMemo(() => {
return getFieldVisibilitiesFromQuery(schema, state);
}, [schema, state]);
Expand Down Expand Up @@ -144,6 +160,7 @@ export const InnerSearchFullUI = ({
page: '1',
}));
};

const setOrderDirection = (direction: string) => {
setState((prev: QueryState) => ({
...prev,
Expand Down Expand Up @@ -338,13 +355,13 @@ export const InnerSearchFullUI = ({
<SeqPreviewModal
seqId={previewedSeqId ?? ''}
accessToken={accessToken}
isOpen={previewedSeqId !== null}
isOpen={Boolean(previewedSeqId)}
onClose={() => setPreviewedSeqId(null)}
referenceGenomeSequenceNames={referenceGenomesSequenceNames}
myGroups={myGroups}
isHalfScreen={previewHalfScreen}
setIsHalfScreen={setPreviewHalfScreen}
setPreviewedSeqId={setPreviewedSeqId}
setPreviewedSeqId={(seqId: string | null) => setPreviewedSeqId(seqId)}
sequenceFlaggingConfig={sequenceFlaggingConfig}
/>
<div className='md:w-[18rem]'>
Expand All @@ -364,7 +381,7 @@ export const InnerSearchFullUI = ({
</div>
<div
className={`md:w-[calc(100%-18.1rem)]`}
style={{ paddingBottom: previewedSeqId !== null && previewHalfScreen ? '50vh' : '0' }}
style={{ paddingBottom: Boolean(previewedSeqId) && previewHalfScreen ? '50vh' : '0' }}
>
<RecentSequencesBanner organism={organism} />

Expand Down Expand Up @@ -468,7 +485,7 @@ export const InnerSearchFullUI = ({
}
selectedSeqs={selectedSeqs}
setSelectedSeqs={setSelectedSeqs}
setPreviewedSeqId={setPreviewedSeqId}
setPreviewedSeqId={(seqId: string | null) => setPreviewedSeqId(seqId)}
previewedSeqId={previewedSeqId}
orderBy={
{
Expand Down
21 changes: 18 additions & 3 deletions website/src/components/SearchPage/SeqPreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export const SeqPreviewModal: React.FC<SeqPreviewModalProps> = ({
className={BUTTONCLASS}
onClick={() => setIsHalfScreen(!isHalfScreen)}
title={isHalfScreen ? 'Expand sequence details view' : 'Dock sequence details view'}
data-testid='toggle-half-screen-button'
>
{isHalfScreen ? (
<MaterialSymbolsLightWidthFull className='w-6 h-6' />
Expand All @@ -126,7 +127,13 @@ export const SeqPreviewModal: React.FC<SeqPreviewModalProps> = ({
<a href={routes.sequenceEntryDetailsPage(seqId)} title='Open in full window' className={BUTTONCLASS}>
<OouiNewWindowLtr className='w-6 h-6' />
</a>
<button type='button' className={BUTTONCLASS} onClick={onClose} title='Close'>
<button
type='button'
className={BUTTONCLASS}
onClick={onClose}
title='Close'
data-testid='close-preview-button'
>
<MaterialSymbolsClose className='w-6 h-6' />
</button>
</div>
Expand All @@ -136,12 +143,20 @@ export const SeqPreviewModal: React.FC<SeqPreviewModalProps> = ({
return (
<Transition appear show={isOpen} as={React.Fragment}>
{isHalfScreen ? (
<div className='fixed bottom-0 w-full left-0 z-40 bg-white p-6 border-t border-gray-400'>
<div
className='fixed bottom-0 w-full left-0 z-40 bg-white p-6 border-t border-gray-400'
data-testid='half-screen-preview'
>
{controls}
{content}
</div>
) : (
<Dialog as='div' className='fixed inset-0 z-40 overflow-y-auto' onClose={onClose}>
<Dialog
as='div'
className='fixed inset-0 z-40 overflow-y-auto'
onClose={onClose}
data-testid='sequence-preview-modal'
>
<div className='min-h-screen px-8 text-center'>
<div className='fixed inset-0 bg-black opacity-30' />
<DialogPanel className='inline-block w-full p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl pb-0'>
Expand Down
1 change: 1 addition & 0 deletions website/src/components/SearchPage/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export const Table: FC<TableProps> = ({
} cursor-pointer`}
onClick={(e) => handleRowClick(e, row[primaryKey] as string)}
onAuxClick={(e) => handleRowClick(e, row[primaryKey] as string)}
data-testid='sequence-row'
>
<td
className='px-2 whitespace-nowrap text-primary-900 md:pl-6'
Expand Down
77 changes: 77 additions & 0 deletions website/src/hooks/useUrlParamState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from 'react';

type ParamType = 'string' | 'boolean' | 'nullable-string';

/**
* A hook that syncs state with URL parameters.
*
* @param paramName The name of the URL parameter to sync with
* @param queryState The current URL query state object
* @param defaultValue The default value to use if the parameter is not present in the URL
* @param setState Function to update the URL query state
* @param paramType Type of the parameter for proper parsing/serialization
* @param shouldRemove Function to determine if the parameter should be removed from URL
* @returns [value, setValue] tuple similar to useState
*/
function useUrlParamState<T>(
paramName: string,
queryState: Record<string, string>,
defaultValue: T,
setState: (callback: (prev: Record<string, string>) => Record<string, string>) => void,
paramType: ParamType = 'string',
shouldRemove: (value: T) => boolean,
): [T, (newValue: T) => void] {
const [valueState, setValueState] = useState<T>(
paramName in queryState ? parseUrlValue(queryState[paramName], paramType) : defaultValue,
);

function parseUrlValue(urlValue: string, type: ParamType): T {
switch (type) {
case 'boolean':
return (urlValue === 'true') as T;
case 'nullable-string':
return (urlValue || null) as T;
case 'string':
default:
return urlValue as T;
}
}

const updateUrlParam = useCallback(
(newValue: T) => {
setState((prev: Record<string, string>) => {
if (shouldRemove(newValue)) {
const newState = { ...prev };
delete newState[paramName];
return newState;
} else {
return {
...prev,
[paramName]: String(newValue),
};
}
});
},
[paramName, setState, shouldRemove],
);

const setValue = useCallback(
(newValue: T) => {
setValueState(newValue);
updateUrlParam(newValue);
},
[updateUrlParam],
);

useEffect(() => {
const urlValue = paramName in queryState ? parseUrlValue(queryState[paramName], paramType) : defaultValue;

if (JSON.stringify(urlValue) !== JSON.stringify(valueState)) {
setValueState(urlValue);
}
}, [queryState, paramName, paramType, defaultValue, valueState]);

return [valueState, setValue];
}

export default useUrlParamState;
Loading