Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG-cat-1533.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Improve search state readability for sharing links.
43 changes: 23 additions & 20 deletions context/app/static/js/components/Providers.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { PropsWithChildren, useMemo } from 'react';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { SWRConfig } from 'swr';
import { faro } from '@grafana/faro-web-sdk';
import { ThemeProvider } from '@mui/material/styles';
Expand Down Expand Up @@ -107,25 +108,27 @@ export default function Providers({
const { springs } = useEntityHeaderSprings();

return (
<SWRConfig value={swrConfig}>
<InitialHashContextProvider>
<GlobalFonts />
<ThemeProvider theme={theme}>
<AppContext.Provider value={appContext}>
<FlaskDataContext.Provider value={flaskDataWithDefaults}>
<EntityStoreProvider springs={springs}>
<OpenKeyNavStoreProvider initialize={readOpenKeyNavCookie()}>
<ProtocolAPIContext.Provider value={protocolsContext}>
<CssBaseline />
<GlobalStyles />
{children}
</ProtocolAPIContext.Provider>
</OpenKeyNavStoreProvider>
</EntityStoreProvider>
</FlaskDataContext.Provider>
</AppContext.Provider>
</ThemeProvider>
</InitialHashContextProvider>
</SWRConfig>
<NuqsAdapter>
<SWRConfig value={swrConfig}>
<InitialHashContextProvider>
<GlobalFonts />
<ThemeProvider theme={theme}>
<AppContext.Provider value={appContext}>
<FlaskDataContext.Provider value={flaskDataWithDefaults}>
<EntityStoreProvider springs={springs}>
<OpenKeyNavStoreProvider initialize={readOpenKeyNavCookie()}>
<ProtocolAPIContext.Provider value={protocolsContext}>
<CssBaseline />
<GlobalStyles />
{children}
</ProtocolAPIContext.Provider>
</OpenKeyNavStoreProvider>
</EntityStoreProvider>
</FlaskDataContext.Provider>
</AppContext.Provider>
</ThemeProvider>
</InitialHashContextProvider>
</SWRConfig>
</NuqsAdapter>
);
}
16 changes: 13 additions & 3 deletions context/app/static/js/components/search/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
import LZString from 'lz-string';
import merge from 'deepmerge';
import history from 'history/browser';
import { isLegacyCompressedURL, parseReadableParams } from './searchParams';

import { useAppContext } from 'js/components/Contexts';
import BulkDownloadSuccessAlert from 'js/components/bulkDownload/BulkDownloadSuccessAlert';
Expand Down Expand Up @@ -349,9 +350,18 @@ function useInitialURLState() {
});

useEffect(() => {
const searchParams = history?.location?.search
? parseURLState(LZString.decompressFromEncodedURIComponent(history?.location?.search?.slice(1)))
: {};
const locationSearch = history?.location?.search ?? '';
let searchParams: Partial<SearchURLState>;

if (!locationSearch) {
searchParams = {};
} else if (isLegacyCompressedURL(locationSearch)) {
// Old format: entire query string is a single LZString-compressed blob
searchParams = parseURLState(LZString.decompressFromEncodedURIComponent(locationSearch.slice(1)));
} else {
// New format: named readable params + optional compressed q param
searchParams = parseReadableParams(locationSearch);
}

let isDoneLoading = true;

Expand Down
183 changes: 183 additions & 0 deletions context/app/static/js/components/search/searchParams.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import LZString from 'lz-string';
import {
encodeHierarchical,
decodeHierarchical,
isLegacyCompressedURL,
parseReadableParams,
READABLE_PARAM_FIELDS,
} from './searchParams';
import { parseURLState } from './store';

// A real LZString-compressed legacy URL fixture encoding:
// { organ: ['Kidney', 'Liver'], raw_dataset_type: { CODEX: ['CODEX [Akoya Polaris]'] } }
const LEGACY_COMPRESSED_BLOB =
'N4IgzgpghgTgxgCxALhCANOA9jALgMQEsIAbAExVADNjyUQSoxcB9AWyzMJojJd0JsIzKGwAOGEFxgQ4ArADt6ZYXBABfTDRK4IMMJRA5CAc0IKWYUWJLCWAVwWEAjvYjsoYsbxY4TUBQNkUFwAT296ABUAUQAlAFlJADcoEjcggG0QAGlCMgUIUMkAGUIkvRAAXU0QGCgAdxYyKFwmCFYwiOCQToh6AAkASTiAQViAYSHxkeLk1PTDcYB5ABFogA0ULOW19YACDJGAayxQqD2ABSxGGEIwSqr1J-UgA';

const LEGACY_COMPRESSED_STATE = {
search: '',
sortField: { field: 'last_modified_timestamp', direction: 'desc' },
filters: {
origin_samples_unique_mapped_organs: { type: 'TERM', values: ['Kidney', 'Liver'] },
raw_dataset_type: { type: 'HIERARCHICAL', values: { CODEX: ['CODEX [Akoya Polaris]'] } },
},
};

describe('encodeHierarchical', () => {
test('encodes each child as parent.child', () => {
const result = encodeHierarchical({ CODEX: new Set(['assay1', 'assay2']) });
expect(result).toEqual(expect.arrayContaining(['CODEX.assay1', 'CODEX.assay2']));
expect(result).toHaveLength(2);
});

test('encodes a parent with no children as parent-only', () => {
expect(encodeHierarchical({ CODEX: new Set([]) })).toEqual(['CODEX']);
});

test('handles multiple parents', () => {
const result = encodeHierarchical({
CODEX: new Set(['child1']),
'snRNA-seq': new Set(['child2', 'child3']),
});
expect(result).toEqual(expect.arrayContaining(['CODEX.child1', 'snRNA-seq.child2', 'snRNA-seq.child3']));
expect(result).toHaveLength(3);
});
});

describe('decodeHierarchical', () => {
test('decodes dot-notation values back to parent → children map', () => {
expect(decodeHierarchical(['CODEX.assay1', 'CODEX.assay2'])).toEqual({ CODEX: ['assay1', 'assay2'] });
});

test('decodes parent-only value to empty children array', () => {
expect(decodeHierarchical(['CODEX'])).toEqual({ CODEX: [] });
});

test('groups multiple children under the same parent', () => {
const result = decodeHierarchical(['snRNA-seq.child1', 'snRNA-seq.child2', 'CODEX.child3']);
expect(result).toEqual({ 'snRNA-seq': ['child1', 'child2'], CODEX: ['child3'] });
});
});

describe('encodeHierarchical / decodeHierarchical round-trip', () => {
test('round-trips a typical hierarchical filter', () => {
const original: Record<string, Set<string>> = {
CODEX: new Set(['CODEX [Akoya Polaris]', 'CODEX [Akoya CODEX]']),
'snRNA-seq': new Set(['snRNA-seq [10x Chromium v3]']),
};
const encoded = encodeHierarchical(original);
const decoded = decodeHierarchical(encoded);
// Convert back to Set form for comparison
const roundTripped = Object.fromEntries(Object.entries(decoded).map(([k, v]) => [k, new Set(v)]));
expect(roundTripped).toEqual(original);
});
});

describe('isLegacyCompressedURL', () => {
test('returns true for a real LZString-compressed blob (no = sign)', () => {
expect(isLegacyCompressedURL(`?${LEGACY_COMPRESSED_BLOB}`)).toBe(true);
});

test('returns false for a new-format query string with named params', () => {
expect(isLegacyCompressedURL('?organ=Kidney&organ=Liver')).toBe(false);
});

test('returns false for a query string with a q param', () => {
expect(isLegacyCompressedURL('?dataset_type=CODEX&q=abc123')).toBe(false);
});

test('returns false for an empty search string', () => {
expect(isLegacyCompressedURL('')).toBe(false);
expect(isLegacyCompressedURL('?')).toBe(false);
});
});

describe('parseReadableParams', () => {
test('parses organ TERM filter from named params', () => {
const result = parseReadableParams('?organ=Kidney&organ=Liver');
expect(result.filters?.['origin_samples_unique_mapped_organs']).toEqual({
type: 'TERM',
values: ['Kidney', 'Liver'],
});
});

test('parses analyte TERM filter', () => {
const result = parseReadableParams('?analyte=RNA');
expect(result.filters?.['analyte_class']).toEqual({ type: 'TERM', values: ['RNA'] });
});

test('parses dataset_type HIERARCHICAL filter with dot-notation', () => {
const result = parseReadableParams('?dataset_type=CODEX.child1&dataset_type=CODEX.child2');
expect(result.filters?.['raw_dataset_type']).toEqual({
type: 'HIERARCHICAL',
values: { CODEX: ['child1', 'child2'] },
});
});

test('parses status HIERARCHICAL filter with dot-notation', () => {
const result = parseReadableParams('?status=Published.Public');
expect(result.filters?.['mapped_status']).toEqual({
type: 'HIERARCHICAL',
values: { Published: ['Public'] },
});
});

test('parses compressed q param and merges with named params', () => {
const remaining = { search: 'myquery', sortField: { field: 'created_timestamp', direction: 'asc' }, filters: {} };
const q = LZString.compressToEncodedURIComponent(JSON.stringify(remaining));
const result = parseReadableParams(`?organ=Kidney&q=${q}`);
expect(result.search).toBe('myquery');
expect(result.sortField).toEqual({ field: 'created_timestamp', direction: 'asc' });
expect(result.filters?.['origin_samples_unique_mapped_organs']).toEqual({ type: 'TERM', values: ['Kidney'] });
});

test('returns empty object for empty search string', () => {
const result = parseReadableParams('');
expect(result).toEqual({ filters: {} });
});

test('readable params take precedence over same field in q', () => {
// q contains organ filter, but named param should win
const qState = {
filters: { origin_samples_unique_mapped_organs: { type: 'TERM', values: ['Brain'] } },
};
const q = LZString.compressToEncodedURIComponent(JSON.stringify(qState));
const result = parseReadableParams(`?organ=Kidney&q=${q}`);
expect(result.filters?.['origin_samples_unique_mapped_organs']).toEqual({ type: 'TERM', values: ['Kidney'] });
});
});

describe('backward compatibility: legacy compressed URLs', () => {
test('LEGACY_COMPRESSED_BLOB round-trips through LZString', () => {
const decompressed = LZString.decompressFromEncodedURIComponent(LEGACY_COMPRESSED_BLOB);
expect(decompressed).toBeTruthy();
const parsed: unknown = JSON.parse(decompressed);
expect(parsed).toEqual(LEGACY_COMPRESSED_STATE);
});

test('isLegacyCompressedURL correctly identifies the legacy fixture', () => {
expect(isLegacyCompressedURL(`?${LEGACY_COMPRESSED_BLOB}`)).toBe(true);
});

test('parseURLState correctly deserializes the legacy fixture via LZString', () => {
const decompressed = LZString.decompressFromEncodedURIComponent(LEGACY_COMPRESSED_BLOB);
const result = parseURLState(decompressed);
expect(result.filters?.['origin_samples_unique_mapped_organs']).toEqual({
type: 'TERM',
values: ['Kidney', 'Liver'],
});
expect(result.filters?.['raw_dataset_type']).toEqual({
type: 'HIERARCHICAL',
values: { CODEX: ['CODEX [Akoya Polaris]'] },
});
expect(result.sortField).toEqual({ field: 'last_modified_timestamp', direction: 'desc' });
});

test('READABLE_PARAM_FIELDS maps the 4 important facets', () => {
expect(READABLE_PARAM_FIELDS).toMatchObject({
origin_samples_unique_mapped_organs: 'organ',
analyte_class: 'analyte',
raw_dataset_type: 'dataset_type',
mapped_status: 'status',
});
});
});
Loading
Loading