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
23 changes: 16 additions & 7 deletions frontend/src/app/core/data/browse-response.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
BrowseFacetGroup,
BrowseFacetResult,
BrowseFacetValue,
BrowseLink,
BrowsePage,
Expand Down Expand Up @@ -41,13 +42,21 @@ export function mapBrowsePage<T>(response: RawBrowsePage<T>): BrowsePage<T> {
};
}

export function mapBrowseFacetGroups(response: RawFacetResponse): BrowseFacetGroup[] {
return response.facets.map(group => ({
rel: group.metadata.rel,
key: group.metadata.key,
title: group.metadata.title,
values: group.links.map(mapBrowseFacetValue),
}));
export function mapBrowseFacetResult(response: RawFacetResponse): BrowseFacetResult {
const facets: BrowseFacetGroup[] = [];
const sortTokens: string[] = [];
for (const group of response.facets) {
if (group.metadata.rel === 'facet') {
facets.push({
key: group.metadata.key,
title: group.metadata.title,
values: group.links.map(mapBrowseFacetValue),
});
} else if (group.metadata.rel === 'sort') {
sortTokens.push(...group.links.map(link => link.value));
}
}
return {facets, sortTokens};
}

function mapBrowseLink(raw: RawLink): BrowseLink {
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/app/core/data/browse.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@ export interface BrowseFacetValue {
}

export interface BrowseFacetGroup {
rel: string;
key: string;
title: string;
values: BrowseFacetValue[];
}

export interface BrowseFacetResult {
facets: BrowseFacetGroup[];
sortTokens: string[];
}

export type BrowseFacetLogic = 'and' | 'or' | 'not';
export type BrowseSortDirection = 'asc' | 'desc';

Expand Down
146 changes: 146 additions & 0 deletions frontend/src/app/features/book/browse/book-browse-columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {formatMediumDate} from '../../../shared/util/date-format';
import {formatFileSizeKb} from '../../../shared/util/file-size';
import {type TableColumnPreference} from '../../settings/user-management/user.service';
import {type BookQuerySortKey} from '../data/book-query-params';
import {type BookSummary} from '../data/book-response.models';
import {
COLUMNS,
COLUMNS_BY_KEY,
COLUMN_GROUP_ORDER,
bookReadStatusLabelKey,
type BookBrowseColumnGroupId,
type BookColumnKey,
type BookColumnKind,
type BookColumnValue,
} from './book-browse-fields';

export const BOOK_EMPTY_VALUE = '—';

const DEFAULT_COLUMN_MIN_WIDTH = 84;
const DEFAULT_COLUMN_MAX_WIDTH = 420;
const NUMBER_FORMAT = new Intl.NumberFormat();

export interface BookColumnSizing {
readonly size: number;
readonly minSize: number;
readonly maxSize: number;
}

export interface BookColumnOption {
readonly field: BookColumnKey;
readonly labelKey: string;
readonly visible: boolean;
readonly hideable: boolean;
}

export interface BookBrowseColumnVisibilityChange {
readonly field: string;
readonly visible: boolean;
}

export interface BookColumnSection {
readonly id: BookBrowseColumnGroupId;
readonly columns: readonly BookColumnOption[];
}

export function normalizeBookColumnPreferences(
saved: readonly TableColumnPreference[] | undefined,
): TableColumnPreference[] {
const savedByField = new Map(saved?.map(preference => [preference.field, preference]));
return COLUMNS.map(({key, column}, order) => {
const preference = savedByField.get(key);
return {
field: key,
visible: column.hideable === false || (preference?.visible ?? column.defaultVisible),
order: preference?.order ?? order,
};
});
}

export function bookColumnOptions(
preferences: readonly TableColumnPreference[],
): BookColumnOption[] {
const visibleByField = new Map(
preferences.map(preference => [preference.field, preference.visible]),
);
return COLUMNS.map(({key, labelKey, column}) => ({
field: key,
labelKey,
visible: visibleByField.get(key) === true,
hideable: column.hideable !== false,
}));
}

export function bookVisibleColumnOptions(
preferences: readonly TableColumnPreference[],
): BookColumnOption[] {
const orderByField = new Map(preferences.map(preference => [preference.field, preference.order]));
const orderOf = (option: BookColumnOption) =>
option.field === 'title' ? -1 : orderByField.get(option.field) ?? Number.MAX_SAFE_INTEGER;
return bookColumnOptions(preferences)
.filter(option => option.visible)
.sort((first, second) => orderOf(first) - orderOf(second));
}

export function bookColumnSections(
options: readonly BookColumnOption[],
): BookColumnSection[] {
const optionsByField = new Map(options.map(option => [option.field, option]));
return COLUMN_GROUP_ORDER.map(id => ({
id,
columns: COLUMNS.flatMap(({key, column}) => {
const option = column.group === id ? optionsByField.get(key) : undefined;
return option ? [option] : [];
}),
}));
}

export function bookColumnSortKey(field: string): BookQuerySortKey | undefined {
return COLUMNS_BY_KEY.get(field)?.sort?.key;
}

export function bookColumnKind(field: string): BookColumnKind {
return COLUMNS_BY_KEY.get(field)?.column.kind ?? 'text';
}

export function bookColumnSizing(field: BookColumnKey): BookColumnSizing {
const {defaultWidth, minWidth, maxWidth} = COLUMNS_BY_KEY.get(field)!.column;
return {
size: defaultWidth,
minSize: minWidth ?? DEFAULT_COLUMN_MIN_WIDTH,
maxSize: maxWidth ?? DEFAULT_COLUMN_MAX_WIDTH,
};
}

export function bookColumnValue(
book: BookSummary,
field: string,
): BookColumnValue {
return COLUMNS_BY_KEY.get(field)?.column.value(book);
}

export function formatBookValue(
kind: BookColumnKind,
value: BookColumnValue,
translate: (key: string) => string,
): string {
if (value == null || value === '') {
return BOOK_EMPTY_VALUE;
}
switch (kind) {
case 'date':
return formatMediumDate(String(value)) || BOOK_EMPTY_VALUE;
case 'fileSize':
return formatFileSizeKb(Number(value));
case 'rating':
return Number(value).toFixed(1);
case 'number':
return NUMBER_FORMAT.format(Number(value));
case 'readStatus': {
const labelKey = typeof value === 'string' ? bookReadStatusLabelKey(value) : null;
return labelKey ? translate(labelKey) : BOOK_EMPTY_VALUE;
}
case 'text':
return String(value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {describe, expect, it} from 'vitest';

import {browseFilterGroups, browseFrozenFacetOrders, withBrowseFacetRange} from '../../../shared/browse/facets';
import {type BrowseFacetGroup} from '../../../core/data/browse.models';
import {bookFacetDefinitions} from './book-browse-facet-definitions';

function group(key: string, values: [string, number][]): BrowseFacetGroup {
return {
key,
title: key,
values: values.map(([value, count]) => ({value, title: value, count, selected: false})),
};
}

const definitions = bookFacetDefinitions({
shelf: () => undefined,
library: () => undefined,
translate: (key: string) => key,
});

describe('book browse facets', () => {
it('keeps the frozen value order when the server re-ranks, narrows or grows a group', () => {
const frozen = browseFrozenFacetOrders([group('genre', [['Gothic', 40], ['Comedy', 30], ['Drama', 20]])], definitions);

const reranked = browseFilterGroups(
[group('genre', [['Drama', 90], ['Gothic', 5], ['Comedy', 2]])],
frozen,
definitions,
{},
);
expect(reranked[0].values.map(item => [item.value, item.count]))
.toEqual([['Gothic', 5], ['Comedy', 2], ['Drama', 90]]);

const narrowed = browseFilterGroups([group('genre', [['Comedy', 7]])], frozen, definitions, {});
expect(narrowed[0].values.map(item => [item.value, item.count]))
.toEqual([['Comedy', 7], ['Gothic', 0], ['Drama', 0]]);

const selectedAtZero = browseFilterGroups([group('genre', [['Comedy', 7], ['Gothic', 0]])], frozen, definitions, {genre: ['Gothic']});
expect(selectedAtZero[0].values.map(item => item.value)).toEqual(['Gothic', 'Comedy', 'Drama']);

const grown = browseFilterGroups(
[group('genre', [['Farce', 3], ['Gothic', 40], ['Comedy', 30], ['Drama', 20]])],
frozen,
definitions,
{},
);
expect(grown[0].values.map(item => item.value)).toEqual(['Gothic', 'Comedy', 'Drama', 'Farce']);
});

it('replaces a numeric range token rather than stacking it, and keeps band selections', () => {
let selection = withBrowseFacetRange({}, 'page_count', 100, 400, definitions);
expect(selection).toEqual({page_count: ['100..400']});
selection = withBrowseFacetRange(selection, 'page_count', null, 200, definitions);
expect(selection).toEqual({page_count: ['*..200']});
expect(withBrowseFacetRange(selection, 'page_count', null, null, definitions)).toEqual({});
expect(withBrowseFacetRange({match_score: ['70..80']}, 'match_score', 30, 90, definitions))
.toEqual({match_score: ['70..80', '30..90']});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {type BrowseFacetDefinitions} from '../../../shared/browse/facets';
import {
BOOK_QUERY_FACET_KEYS,
isBookQueryFacetKey,
type BookQueryFacetKey,
} from '../data/book-query-params';
import {type BookSummary} from '../data/book-response.models';
import {
COLUMNS_BY_KEY,
FACET_FIELDS,
OPEN_RAIL_FACETS,
type BookFacetEntity,
type BookFacetLabelDeps,
} from './book-browse-fields';

export interface BookFacetLink {
readonly key: BookQueryFacetKey;
readonly value: string;
}

interface BookFacetEntityWithId extends BookFacetEntity {
readonly id?: string | number;
}

export function bookFacetLabelDeps(
shelves: readonly BookFacetEntityWithId[],
libraries: readonly BookFacetEntityWithId[],
translate: (key: string) => string,
): BookFacetLabelDeps {
const byId = (entities: readonly BookFacetEntityWithId[]) =>
new Map(entities.map(entity => [`${entity.id}`, entity]));
const shelvesById = byId(shelves);
const librariesById = byId(libraries);
return {
shelf: id => shelvesById.get(id),
library: id => librariesById.get(id),
translate,
};
}

function registeredFacetLabelKey(key: BookQueryFacetKey): string {
return FACET_FIELDS.get(key)!.labelKey;
}

export function bookFacetDefinitions(deps: BookFacetLabelDeps): BrowseFacetDefinitions<BookQueryFacetKey> {
return {
order: BOOK_QUERY_FACET_KEYS,
isKey: isBookQueryFacetKey,
labelKey: registeredFacetLabelKey,
openByDefault: OPEN_RAIL_FACETS,
kind: key => FACET_FIELDS.get(key)?.facet.kind,
valueOrder: key => FACET_FIELDS.get(key)?.facet.order,
valueDomain: key => FACET_FIELDS.get(key)?.facet.domain,
valueBuckets: key => FACET_FIELDS.get(key)?.facet.buckets,
fileSize: key => FACET_FIELDS.get(key)?.facet.fileSize ?? false,
valueLabel: (key, value) => FACET_FIELDS.get(key)?.facet.valueLabel?.(value, deps) ?? null,
valueIcon: (key, value) => FACET_FIELDS.get(key)?.facet.valueIcon?.(value, deps) ?? null,
};
}

export function bookFacetLinks(
book: BookSummary,
columnField: string,
): readonly BookFacetLink[] {
const facet = COLUMNS_BY_KEY.get(columnField)?.facet;
if (!facet?.bookValues) {
return [];
}
const key = facet.key;
return facet.bookValues(book).map(value => ({key, value}));
}
Loading
Loading