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
42 changes: 42 additions & 0 deletions frontend/src/app/core/data/browse-query-keys.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {describe, expect, it} from 'vitest';

import {
BrowseCollectionFilterParams,
BrowsePageParams,
BrowseQueryParams,
normalizeBrowseCollectionFilterParams,
normalizeBrowsePageParams,
} from './browse-query-params';
import {createBrowseQueryKeys} from './browse-query-keys';

type TestCollectionParams = BrowseCollectionFilterParams<'genre'>;
type TestQueryParams = BrowseQueryParams<'genre', 'title'>;
type TestPageParams = BrowsePageParams<'genre', 'title'>;

const query: TestQueryParams = {
query: 'dune',
facets: {genre: ['Fantasy']},
facetLogic: 'or',
sort: [{key: 'title', direction: 'asc'}],
};
const page = normalizeBrowsePageParams({...query, size: 20});
const keys = createBrowseQueryKeys<
TestCollectionParams,
TestQueryParams,
TestPageParams
>('items');

describe('browse query keys', () => {
it('keeps bounded and infinite data shapes on different leaves', () => {
expect(keys.boundedPage(page)).not.toEqual(keys.infinitePage(page));
expect(keys.boundedPage(page).at(-1)).toBe(page);
expect(keys.infinitePage(page).at(-1)).toBe(page);
});

it('keeps facet selection as part of query identity', () => {
const genreSelected = normalizeBrowseCollectionFilterParams(query);
const unfiltered = normalizeBrowseCollectionFilterParams({...query, facets: {}});

expect(keys.facets(genreSelected)).not.toEqual(keys.facets(unfiltered));
});
});
21 changes: 21 additions & 0 deletions frontend/src/app/core/data/browse-query-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export function createBrowseQueryKeys<CollectionParams, QueryParams, PageParams>(resource: string) {
const all = () => [resource, 'query'] as const;
const collections = () => [...all(), 'collection'] as const;
const boundedPages = () => [...collections(), 'page', 'bounded'] as const;
const infinitePages = () => [...collections(), 'page', 'infinite'] as const;
const facetQueries = () => [...collections(), 'facets'] as const;
const idQueries = () => [...collections(), 'ids'] as const;

return {
all,
collections,
boundedPages,
boundedPage: (params: PageParams) => [...boundedPages(), params] as const,
infinitePages,
infinitePage: (params: PageParams) => [...infinitePages(), params] as const,
facetQueries,
facets: (params: CollectionParams) => [...facetQueries(), params] as const,
idQueries,
ids: (params: QueryParams) => [...idQueries(), params] as const,
};
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import {describe, expect, it} from 'vitest';

import {
normalizeBookCollectionFilterParams,
normalizeBookQueryParams,
normalizeBookPageParams,
toCollectionHttpParams,
toIdsHttpParams,
toPageHttpParams,
} from './book-query-params';
normalizeBrowseCollectionFilterParams,
normalizeBrowsePageParams,
normalizeBrowseQueryParams,
toBrowseCollectionHttpParams,
toBrowseIdsHttpParams,
toBrowsePageHttpParams,
} from './browse-query-params';

describe('book query parameters', () => {
describe('browse query parameters', () => {
it('normalizes equivalent queries and facet selections', () => {
const first = normalizeBookPageParams({
const first = normalizeBrowsePageParams({
query: ' dune ',
facets: {
language: [' French ', 'English'],
Expand All @@ -21,7 +21,7 @@ describe('book query parameters', () => {
sort: [{key: 'title', direction: 'asc'}],
size: 40,
});
const second = normalizeBookPageParams({
const second = normalizeBrowsePageParams({
query: 'dune',
facets: {
genre: ['Fantasy', 'Science Fiction'],
Expand All @@ -40,7 +40,7 @@ describe('book query parameters', () => {
});

it('passes an empty sort through without imposing a default', () => {
const normalized = normalizeBookQueryParams({
const normalized = normalizeBrowseQueryParams({
facets: {},
facetLogic: 'or',
sort: [],
Expand All @@ -50,14 +50,14 @@ describe('book query parameters', () => {
});

it.each(['and'] as const)('preserves explicit %s facet logic', facetLogic => {
const normalized = normalizeBookQueryParams({facets: {}, facetLogic, sort: []});
const normalized = normalizeBrowseQueryParams({facets: {}, facetLogic, sort: []});

expect(normalized.facetLogic).toBe(facetLogic);
expect(toIdsHttpParams(normalized).get('facet_logic')).toBe(facetLogic);
expect(toBrowseIdsHttpParams(normalized).get('facet_logic')).toBe(facetLogic);
});

it('serializes page parameters using the backend vocabulary', () => {
const params = toPageHttpParams(normalizeBookPageParams({
const params = toBrowsePageHttpParams(normalizeBrowsePageParams({
query: 'dune',
facets: {
genre: ['Science Fiction'],
Expand All @@ -80,7 +80,7 @@ describe('book query parameters', () => {
});

it('excludes sort and size from facet requests', () => {
const params = toCollectionHttpParams(normalizeBookCollectionFilterParams({
const params = toBrowseCollectionHttpParams(normalizeBrowseCollectionFilterParams({
query: 'dune',
facets: {genre: ['Fantasy']},
facetLogic: 'and',
Expand All @@ -94,7 +94,7 @@ describe('book query parameters', () => {
});

it('does not emit facet parameters for an empty selection', () => {
const params = toCollectionHttpParams(normalizeBookCollectionFilterParams({
const params = toBrowseCollectionHttpParams(normalizeBrowseCollectionFilterParams({
facets: {},
facetLogic: 'or',
}));
Expand All @@ -103,7 +103,7 @@ describe('book query parameters', () => {
});

it('includes sort but excludes size from ID requests', () => {
const params = toIdsHttpParams(normalizeBookQueryParams({
const params = toBrowseIdsHttpParams(normalizeBrowseQueryParams({
facets: {},
facetLogic: 'or',
sort: [{key: 'title', direction: 'desc'}],
Expand Down
132 changes: 132 additions & 0 deletions frontend/src/app/core/data/browse-query-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {HttpParams} from '@angular/common/http';

import {
BrowseFacetLogic,
BrowseSortTerm,
} from './browse.models';

export type BrowseFacetValueMap<Key extends string> =
Readonly<Partial<Record<Key, readonly string[]>>>;

export interface BrowseCollectionFilterParams<FacetKey extends string> {
query?: string;
facets: BrowseFacetValueMap<FacetKey>;
facetLogic: BrowseFacetLogic;
}

export interface BrowseQueryParams<FacetKey extends string, SortKey extends string>
extends BrowseCollectionFilterParams<FacetKey> {
sort: readonly BrowseSortTerm<SortKey>[];
}

export interface BrowsePageParams<FacetKey extends string, SortKey extends string>
extends BrowseQueryParams<FacetKey, SortKey> {
size: number;
}

export function normalizeBrowsePageParams<FacetKey extends string, SortKey extends string>(
params: BrowsePageParams<FacetKey, SortKey>,
): BrowsePageParams<FacetKey, SortKey> {
return {
...normalizeBrowseQueryParams(params),
size: params.size,
};
}

export function normalizeBrowseQueryParams<FacetKey extends string, SortKey extends string>(
params: BrowseQueryParams<FacetKey, SortKey>,
): BrowseQueryParams<FacetKey, SortKey> {
return {
...normalizeBrowseCollectionFilterParams(params),
sort: params.sort,
};
}

export function normalizeBrowseCollectionFilterParams<FacetKey extends string>(
params: BrowseCollectionFilterParams<FacetKey>,
): BrowseCollectionFilterParams<FacetKey> {
const query = params.query?.trim();
const facets = normalizeFacetValueMap(params.facets);

return {
...(query ? {query} : {}),
facets,
facetLogic: params.facetLogic,
};
}

export function toBrowsePageHttpParams<FacetKey extends string, SortKey extends string>(
params: BrowsePageParams<FacetKey, SortKey>,
): HttpParams {
return appendSortParam(toBrowseCollectionHttpParams(params), params.sort)
.set('size', params.size.toString());
}

export function toBrowseIdsHttpParams<FacetKey extends string, SortKey extends string>(
params: BrowseQueryParams<FacetKey, SortKey>,
): HttpParams {
return appendSortParam(toBrowseCollectionHttpParams(params), params.sort);
}

export function toBrowseCollectionHttpParams<FacetKey extends string>(
params: BrowseCollectionFilterParams<FacetKey>,
): HttpParams {
let httpParams = new HttpParams().set('facet_logic', params.facetLogic);

if (params.query) {
httpParams = httpParams.set('query', params.query);
}

return appendFacetParams(httpParams, params.facets);
}

function appendSortParam<SortKey extends string>(
httpParams: HttpParams,
sort: readonly BrowseSortTerm<SortKey>[],
): HttpParams {
return sort.length === 0 ? httpParams : httpParams.set('sort', serializeSort(sort));
}

function normalizeFacetValueMap<Key extends string>(
facets: BrowseFacetValueMap<Key>,
): BrowseFacetValueMap<Key> {
const normalized: Partial<Record<Key, readonly string[]>> = {};
const keys = (Object.keys(facets) as Key[]).sort(compareCodeUnits);

for (const key of keys) {
const values = [...new Set((facets[key] ?? [])
.map(value => value.trim())
.filter(Boolean))].sort(compareCodeUnits);
if (values.length > 0) {
normalized[key] = values;
}
}

return normalized;
}

function appendFacetParams<Key extends string>(
httpParams: HttpParams,
facets: BrowseFacetValueMap<Key>,
): HttpParams {
let result = httpParams;
for (const key of Object.keys(facets) as Key[]) {
for (const value of facets[key] ?? []) {
result = result.append('facet', `${key}:${value}`);
}
}

return result;
}

function serializeSort<SortKey extends string>(
sort: readonly BrowseSortTerm<SortKey>[],
): string {
return sort
.map(term => `${term.direction === 'desc' ? '-' : ''}${term.key}`)
.join(',');
}

function compareCodeUnits(first: string, second: string): number {
return first < second ? -1 : first > second ? 1 : 0;
}
Loading
Loading