diff --git a/integration-tests/tests/pages/search.page.ts b/integration-tests/tests/pages/search.page.ts index ff10cdc5b2..4ecb9dfb71 100644 --- a/integration-tests/tests/pages/search.page.ts +++ b/integration-tests/tests/pages/search.page.ts @@ -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; + } } diff --git a/integration-tests/tests/specs/features/sequence-preview-url.spec.ts b/integration-tests/tests/specs/features/sequence-preview-url.spec.ts new file mode 100644 index 0000000000..52e5d449e0 --- /dev/null +++ b/integration-tests/tests/specs/features/sequence-preview-url.spec.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/website/src/components/SearchPage/SearchFullUI.tsx b/website/src/components/SearchPage/SearchFullUI.tsx index 0b1c47911e..df89e8a0ef 100644 --- a/website/src/components/SearchPage/SearchFullUI.tsx +++ b/website/src/components/SearchPage/SearchFullUI.tsx @@ -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'; @@ -94,10 +95,25 @@ export const InnerSearchFullUI = ({ return consolidateGroupedFields(metadataSchemaWithExpandedRanges); }, [metadataSchema]); - const [previewedSeqId, setPreviewedSeqId] = useState(null); - const [previewHalfScreen, setPreviewHalfScreen] = useState(false); const [state, setState] = useQueryAsState(initialQueryDict); + const [previewedSeqId, setPreviewedSeqId] = useUrlParamState( + '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]); @@ -144,6 +160,7 @@ export const InnerSearchFullUI = ({ page: '1', })); }; + const setOrderDirection = (direction: string) => { setState((prev: QueryState) => ({ ...prev, @@ -338,13 +355,13 @@ export const InnerSearchFullUI = ({ setPreviewedSeqId(null)} referenceGenomeSequenceNames={referenceGenomesSequenceNames} myGroups={myGroups} isHalfScreen={previewHalfScreen} setIsHalfScreen={setPreviewHalfScreen} - setPreviewedSeqId={setPreviewedSeqId} + setPreviewedSeqId={(seqId: string | null) => setPreviewedSeqId(seqId)} sequenceFlaggingConfig={sequenceFlaggingConfig} />
@@ -364,7 +381,7 @@ export const InnerSearchFullUI = ({
@@ -468,7 +485,7 @@ export const InnerSearchFullUI = ({ } selectedSeqs={selectedSeqs} setSelectedSeqs={setSelectedSeqs} - setPreviewedSeqId={setPreviewedSeqId} + setPreviewedSeqId={(seqId: string | null) => setPreviewedSeqId(seqId)} previewedSeqId={previewedSeqId} orderBy={ { diff --git a/website/src/components/SearchPage/SeqPreviewModal.tsx b/website/src/components/SearchPage/SeqPreviewModal.tsx index 06b8827a7d..8f2fbb275a 100644 --- a/website/src/components/SearchPage/SeqPreviewModal.tsx +++ b/website/src/components/SearchPage/SeqPreviewModal.tsx @@ -115,6 +115,7 @@ export const SeqPreviewModal: React.FC = ({ className={BUTTONCLASS} onClick={() => setIsHalfScreen(!isHalfScreen)} title={isHalfScreen ? 'Expand sequence details view' : 'Dock sequence details view'} + data-testid='toggle-half-screen-button' > {isHalfScreen ? ( @@ -126,7 +127,13 @@ export const SeqPreviewModal: React.FC = ({ -
@@ -136,12 +143,20 @@ export const SeqPreviewModal: React.FC = ({ return ( {isHalfScreen ? ( -
+
{controls} {content}
) : ( - +
diff --git a/website/src/components/SearchPage/Table.tsx b/website/src/components/SearchPage/Table.tsx index bb6540bd2a..2456cbf6ce 100644 --- a/website/src/components/SearchPage/Table.tsx +++ b/website/src/components/SearchPage/Table.tsx @@ -180,6 +180,7 @@ export const Table: FC = ({ } cursor-pointer`} onClick={(e) => handleRowClick(e, row[primaryKey] as string)} onAuxClick={(e) => handleRowClick(e, row[primaryKey] as string)} + data-testid='sequence-row' > ( + paramName: string, + queryState: Record, + defaultValue: T, + setState: (callback: (prev: Record) => Record) => void, + paramType: ParamType = 'string', + shouldRemove: (value: T) => boolean, +): [T, (newValue: T) => void] { + const [valueState, setValueState] = useState( + 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) => { + 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;