Skip to content

Commit e77db36

Browse files
committed
feat(ui): book browse field registry, facet and sort query helpers
1 parent 472da27 commit e77db36

17 files changed

Lines changed: 1071 additions & 50 deletions

frontend/src/app/core/data/browse-response.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
BrowseFacetGroup,
3+
BrowseFacetResult,
34
BrowseFacetValue,
45
BrowseLink,
56
BrowsePage,
@@ -41,13 +42,21 @@ export function mapBrowsePage<T>(response: RawBrowsePage<T>): BrowsePage<T> {
4142
};
4243
}
4344

44-
export function mapBrowseFacetGroups(response: RawFacetResponse): BrowseFacetGroup[] {
45-
return response.facets.map(group => ({
46-
rel: group.metadata.rel,
47-
key: group.metadata.key,
48-
title: group.metadata.title,
49-
values: group.links.map(mapBrowseFacetValue),
50-
}));
45+
export function mapBrowseFacetResult(response: RawFacetResponse): BrowseFacetResult {
46+
const facets: BrowseFacetGroup[] = [];
47+
const sortTokens: string[] = [];
48+
for (const group of response.facets) {
49+
if (group.metadata.rel === 'facet') {
50+
facets.push({
51+
key: group.metadata.key,
52+
title: group.metadata.title,
53+
values: group.links.map(mapBrowseFacetValue),
54+
});
55+
} else if (group.metadata.rel === 'sort') {
56+
sortTokens.push(...group.links.map(link => link.value));
57+
}
58+
}
59+
return {facets, sortTokens};
5160
}
5261

5362
function mapBrowseLink(raw: RawLink): BrowseLink {

frontend/src/app/core/data/browse.models.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,16 @@ export interface BrowseFacetValue {
2626
}
2727

2828
export interface BrowseFacetGroup {
29-
rel: string;
3029
key: string;
3130
title: string;
3231
values: BrowseFacetValue[];
3332
}
3433

34+
export interface BrowseFacetResult {
35+
facets: BrowseFacetGroup[];
36+
sortTokens: string[];
37+
}
38+
3539
export type BrowseFacetLogic = 'and' | 'or' | 'not';
3640
export type BrowseSortDirection = 'asc' | 'desc';
3741

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import {formatMediumDate} from '../../../shared/util/date-format';
2+
import {formatFileSizeKb} from '../../../shared/util/file-size';
3+
import {type TableColumnPreference} from '../../settings/user-management/user.service';
4+
import {type BookQuerySortKey} from '../data/book-query-params';
5+
import {type BookSummary} from '../data/book-response.models';
6+
import {
7+
COLUMNS,
8+
COLUMNS_BY_KEY,
9+
COLUMN_GROUP_ORDER,
10+
bookReadStatusLabelKey,
11+
type BookBrowseColumnGroupId,
12+
type BookColumnKey,
13+
type BookColumnKind,
14+
type BookColumnValue,
15+
} from './book-browse-fields';
16+
17+
export const BOOK_EMPTY_VALUE = '—';
18+
19+
const DEFAULT_COLUMN_MIN_WIDTH = 84;
20+
const DEFAULT_COLUMN_MAX_WIDTH = 420;
21+
const NUMBER_FORMAT = new Intl.NumberFormat();
22+
23+
export interface BookColumnSizing {
24+
readonly size: number;
25+
readonly minSize: number;
26+
readonly maxSize: number;
27+
}
28+
29+
export interface BookColumnOption {
30+
readonly field: BookColumnKey;
31+
readonly labelKey: string;
32+
readonly visible: boolean;
33+
readonly hideable: boolean;
34+
}
35+
36+
export interface BookBrowseColumnVisibilityChange {
37+
readonly field: string;
38+
readonly visible: boolean;
39+
}
40+
41+
export interface BookColumnSection {
42+
readonly id: BookBrowseColumnGroupId;
43+
readonly columns: readonly BookColumnOption[];
44+
}
45+
46+
export function normalizeBookColumnPreferences(
47+
saved: readonly TableColumnPreference[] | undefined,
48+
): TableColumnPreference[] {
49+
const savedByField = new Map(saved?.map(preference => [preference.field, preference]));
50+
return COLUMNS.map(({key, column}, order) => {
51+
const preference = savedByField.get(key);
52+
return {
53+
field: key,
54+
visible: column.hideable === false || (preference?.visible ?? column.defaultVisible),
55+
order: preference?.order ?? order,
56+
};
57+
});
58+
}
59+
60+
export function bookColumnOptions(
61+
preferences: readonly TableColumnPreference[],
62+
): BookColumnOption[] {
63+
const visibleByField = new Map(
64+
preferences.map(preference => [preference.field, preference.visible]),
65+
);
66+
return COLUMNS.map(({key, labelKey, column}) => ({
67+
field: key,
68+
labelKey,
69+
visible: visibleByField.get(key) === true,
70+
hideable: column.hideable !== false,
71+
}));
72+
}
73+
74+
export function bookVisibleColumnOptions(
75+
preferences: readonly TableColumnPreference[],
76+
): BookColumnOption[] {
77+
const orderByField = new Map(preferences.map(preference => [preference.field, preference.order]));
78+
const orderOf = (option: BookColumnOption) =>
79+
option.field === 'title' ? -1 : orderByField.get(option.field) ?? Number.MAX_SAFE_INTEGER;
80+
return bookColumnOptions(preferences)
81+
.filter(option => option.visible)
82+
.sort((first, second) => orderOf(first) - orderOf(second));
83+
}
84+
85+
export function bookColumnSections(
86+
options: readonly BookColumnOption[],
87+
): BookColumnSection[] {
88+
const optionsByField = new Map(options.map(option => [option.field, option]));
89+
return COLUMN_GROUP_ORDER.map(id => ({
90+
id,
91+
columns: COLUMNS.flatMap(({key, column}) => {
92+
const option = column.group === id ? optionsByField.get(key) : undefined;
93+
return option ? [option] : [];
94+
}),
95+
}));
96+
}
97+
98+
export function bookColumnSortKey(field: string): BookQuerySortKey | undefined {
99+
return COLUMNS_BY_KEY.get(field)?.sort?.key;
100+
}
101+
102+
export function bookColumnKind(field: string): BookColumnKind {
103+
return COLUMNS_BY_KEY.get(field)?.column.kind ?? 'text';
104+
}
105+
106+
export function bookColumnSizing(field: BookColumnKey): BookColumnSizing {
107+
const {defaultWidth, minWidth, maxWidth} = COLUMNS_BY_KEY.get(field)!.column;
108+
return {
109+
size: defaultWidth,
110+
minSize: minWidth ?? DEFAULT_COLUMN_MIN_WIDTH,
111+
maxSize: maxWidth ?? DEFAULT_COLUMN_MAX_WIDTH,
112+
};
113+
}
114+
115+
export function bookColumnValue(
116+
book: BookSummary,
117+
field: string,
118+
): BookColumnValue {
119+
return COLUMNS_BY_KEY.get(field)?.column.value(book);
120+
}
121+
122+
export function formatBookValue(
123+
kind: BookColumnKind,
124+
value: BookColumnValue,
125+
translate: (key: string) => string,
126+
): string {
127+
if (value == null || value === '') {
128+
return BOOK_EMPTY_VALUE;
129+
}
130+
switch (kind) {
131+
case 'date':
132+
return formatMediumDate(String(value)) || BOOK_EMPTY_VALUE;
133+
case 'fileSize':
134+
return formatFileSizeKb(Number(value));
135+
case 'rating':
136+
return Number(value).toFixed(1);
137+
case 'number':
138+
return NUMBER_FORMAT.format(Number(value));
139+
case 'readStatus': {
140+
const labelKey = typeof value === 'string' ? bookReadStatusLabelKey(value) : null;
141+
return labelKey ? translate(labelKey) : BOOK_EMPTY_VALUE;
142+
}
143+
case 'text':
144+
return String(value);
145+
}
146+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {describe, expect, it} from 'vitest';
2+
3+
import {browseFilterGroups, browseFrozenFacetOrders, withBrowseFacetRange} from '../../../shared/browse/facets';
4+
import {type BrowseFacetGroup} from '../../../core/data/browse.models';
5+
import {bookFacetDefinitions} from './book-browse-facet-definitions';
6+
7+
function group(key: string, values: [string, number][]): BrowseFacetGroup {
8+
return {
9+
key,
10+
title: key,
11+
values: values.map(([value, count]) => ({value, title: value, count, selected: false})),
12+
};
13+
}
14+
15+
const definitions = bookFacetDefinitions({
16+
shelf: () => undefined,
17+
library: () => undefined,
18+
translate: (key: string) => key,
19+
});
20+
21+
describe('book browse facets', () => {
22+
it('keeps the frozen value order when the server re-ranks, narrows or grows a group', () => {
23+
const frozen = browseFrozenFacetOrders([group('genre', [['Gothic', 40], ['Comedy', 30], ['Drama', 20]])], definitions);
24+
25+
const reranked = browseFilterGroups(
26+
[group('genre', [['Drama', 90], ['Gothic', 5], ['Comedy', 2]])],
27+
frozen,
28+
definitions,
29+
{},
30+
);
31+
expect(reranked[0].values.map(item => [item.value, item.count]))
32+
.toEqual([['Gothic', 5], ['Comedy', 2], ['Drama', 90]]);
33+
34+
const narrowed = browseFilterGroups([group('genre', [['Comedy', 7]])], frozen, definitions, {});
35+
expect(narrowed[0].values.map(item => [item.value, item.count]))
36+
.toEqual([['Comedy', 7], ['Gothic', 0], ['Drama', 0]]);
37+
38+
const selectedAtZero = browseFilterGroups([group('genre', [['Comedy', 7], ['Gothic', 0]])], frozen, definitions, {genre: ['Gothic']});
39+
expect(selectedAtZero[0].values.map(item => item.value)).toEqual(['Gothic', 'Comedy', 'Drama']);
40+
41+
const grown = browseFilterGroups(
42+
[group('genre', [['Farce', 3], ['Gothic', 40], ['Comedy', 30], ['Drama', 20]])],
43+
frozen,
44+
definitions,
45+
{},
46+
);
47+
expect(grown[0].values.map(item => item.value)).toEqual(['Gothic', 'Comedy', 'Drama', 'Farce']);
48+
});
49+
50+
it('replaces a numeric range token rather than stacking it, and keeps band selections', () => {
51+
let selection = withBrowseFacetRange({}, 'page_count', 100, 400, definitions);
52+
expect(selection).toEqual({page_count: ['100..400']});
53+
selection = withBrowseFacetRange(selection, 'page_count', null, 200, definitions);
54+
expect(selection).toEqual({page_count: ['*..200']});
55+
expect(withBrowseFacetRange(selection, 'page_count', null, null, definitions)).toEqual({});
56+
expect(withBrowseFacetRange({match_score: ['70..80']}, 'match_score', 30, 90, definitions))
57+
.toEqual({match_score: ['70..80', '30..90']});
58+
});
59+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {type BrowseFacetDefinitions} from '../../../shared/browse/facets';
2+
import {
3+
BOOK_QUERY_FACET_KEYS,
4+
isBookQueryFacetKey,
5+
type BookQueryFacetKey,
6+
} from '../data/book-query-params';
7+
import {type BookSummary} from '../data/book-response.models';
8+
import {
9+
COLUMNS_BY_KEY,
10+
FACET_FIELDS,
11+
OPEN_RAIL_FACETS,
12+
type BookFacetEntity,
13+
type BookFacetLabelDeps,
14+
} from './book-browse-fields';
15+
16+
export interface BookFacetLink {
17+
readonly key: BookQueryFacetKey;
18+
readonly value: string;
19+
}
20+
21+
interface BookFacetEntityWithId extends BookFacetEntity {
22+
readonly id?: string | number;
23+
}
24+
25+
export function bookFacetLabelDeps(
26+
shelves: readonly BookFacetEntityWithId[],
27+
libraries: readonly BookFacetEntityWithId[],
28+
translate: (key: string) => string,
29+
): BookFacetLabelDeps {
30+
const byId = (entities: readonly BookFacetEntityWithId[]) =>
31+
new Map(entities.map(entity => [`${entity.id}`, entity]));
32+
const shelvesById = byId(shelves);
33+
const librariesById = byId(libraries);
34+
return {
35+
shelf: id => shelvesById.get(id),
36+
library: id => librariesById.get(id),
37+
translate,
38+
};
39+
}
40+
41+
function registeredFacetLabelKey(key: BookQueryFacetKey): string {
42+
return FACET_FIELDS.get(key)!.labelKey;
43+
}
44+
45+
export function bookFacetDefinitions(deps: BookFacetLabelDeps): BrowseFacetDefinitions<BookQueryFacetKey> {
46+
return {
47+
order: BOOK_QUERY_FACET_KEYS,
48+
isKey: isBookQueryFacetKey,
49+
labelKey: registeredFacetLabelKey,
50+
openByDefault: OPEN_RAIL_FACETS,
51+
kind: key => FACET_FIELDS.get(key)?.facet.kind,
52+
valueOrder: key => FACET_FIELDS.get(key)?.facet.order,
53+
valueDomain: key => FACET_FIELDS.get(key)?.facet.domain,
54+
valueBuckets: key => FACET_FIELDS.get(key)?.facet.buckets,
55+
fileSize: key => FACET_FIELDS.get(key)?.facet.fileSize ?? false,
56+
valueLabel: (key, value) => FACET_FIELDS.get(key)?.facet.valueLabel?.(value, deps) ?? null,
57+
valueIcon: (key, value) => FACET_FIELDS.get(key)?.facet.valueIcon?.(value, deps) ?? null,
58+
};
59+
}
60+
61+
export function bookFacetLinks(
62+
book: BookSummary,
63+
columnField: string,
64+
): readonly BookFacetLink[] {
65+
const facet = COLUMNS_BY_KEY.get(columnField)?.facet;
66+
if (!facet?.bookValues) {
67+
return [];
68+
}
69+
const key = facet.key;
70+
return facet.bookValues(book).map(value => ({key, value}));
71+
}

0 commit comments

Comments
 (0)