Skip to content
Merged
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
28 changes: 27 additions & 1 deletion frontend/src/utils/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,24 @@ export const POPULAR_AUTHORS = [
'Chimamanda Ngozi Adichie', 'Kazuo Ishiguro', 'Salman Rushdie', 'Milan Kundera',
];

/**
* @typedef {Object} FilterState
* @property {''|'new'|'old'|'rating desc'|'readinglog desc'|'title'} sort
* Sort value from SORT_OPTIONS.
* @property {''|'readable'|'borrowable'|'open'} availability
* Availability filter from AVAILABILITY_OPTIONS.
* @property {''|'fiction'|'nonfiction'} fictionFilter
* Fiction/nonfiction filter.
* @property {string[]} [languages]
* ISO 639-2/B codes e.g. ['eng', 'spa'].
* @property {string[]} [genres]
* Values from GENRE_OPTIONS e.g. ['mystery'].
* @property {string[]} [authors]
* Free-text author names.
* @property {string[]} [subjects]
* Free-text subject names.
*/

// DEFAULT_FILTERS is the canonical name — "EMPTY" was a misnomer because
// availability defaults to 'readable', not to a truly empty state.
export const DEFAULT_FILTERS = {
Expand Down Expand Up @@ -173,8 +191,11 @@ export function shufflePick(arr, n) {

/**
* Derive the chip array from a filter state object.
* Each chip label includes a human-readable "type: value" prefix.
* Chip labels are human-readable; language/genre/author/subject labels include a
* "type: value" prefix, while availability and fiction use plain descriptive labels.
* Ordering: availability → fictionFilter → language → genre → author → subject
* @param {FilterState} filters
* @returns {{ type: string, label: string, value: string|null }[]}
*/
export function buildChips(filters) {
const chips = [];
Expand Down Expand Up @@ -223,6 +244,11 @@ export function buildChips(filters) {
* Build URLSearchParams for a search request.
* Multi-value arrays (languages, genres, authors, subjects) are each appended
* as repeated params; the backend ORs them together within each facet.
* @param {string} q
* @param {FilterState} filters
* @param {number} page
* @param {number} limit
* @returns {URLSearchParams}
*/
export function buildSearchParams(q, filters, page, limit) {
const p = new URLSearchParams();
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/utils/filters.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import {
toggleArrayValue,
shufflePick,
Expand Down Expand Up @@ -447,3 +448,34 @@ describe('DEFAULT_FILTERS', () => {
expect(EMPTY_FILTERS).toBeDefined();
});
});

// ── FilterState typedef static-analysis ──────────────────────────────────────

const filtersSrc = readFileSync(new URL('./filters.js', import.meta.url), 'utf8');

describe('FilterState typedef', () => {
it('@typedef {Object} FilterState is declared in filters.js', () => {
expect(filtersSrc).toMatch(/@typedef\s*\{Object\}\s*FilterState/);
});

it('all seven filter fields are documented with @property', () => {
for (const field of ['sort', 'availability', 'fictionFilter', 'languages', 'genres', 'authors', 'subjects']) {
expect(filtersSrc).toMatch(new RegExp(`@property.*${field}`));
}
});

it('buildChips and buildSearchParams each have a @param {FilterState} annotation (2 total)', () => {
const matches = [...filtersSrc.matchAll(/@param\s*\{FilterState\}/g)];
expect(matches.length).toBeGreaterThanOrEqual(2);
});
Comment thread
mekarpeles marked this conversation as resolved.

it('FilterState uses literal-union types for sort, availability, and fictionFilter', () => {
const typedef = filtersSrc.slice(
filtersSrc.indexOf('@typedef {Object} FilterState'),
filtersSrc.indexOf('@typedef {Object} FilterState') + 600,
);
expect(typedef).toMatch(/'new'.*'old'|'old'.*'new'/); // sort has at least two literals
expect(typedef).toMatch(/'readable'/); // availability literal
expect(typedef).toMatch(/'fiction'.*'nonfiction'|'nonfiction'.*'fiction'/); // fictionFilter literals
});
});
Comment thread
mekarpeles marked this conversation as resolved.
Loading