$if books_count > 1:
$:render_template("search/sort_options.html", books.sort, exclude='relevance', default_sort='editions')
$if books_count > 0:
$:render_template("search/layout_options.html", selected_layout=layout)
-
- $if input(mode="everything").mode == "everything":
- $if books_count > 0:
-
$:_('— Show only ebooks?', url=changequery(mode='ebooks'))
- $else:
-
$:_('— Show everything by this author?', url=changequery(mode='everything'))
$:macros.OlPagination(safeint(query_param('page'), default=1), ceil(books_count / 20))
+ $# Escape hatch: an inherited/active filter that hides every work
+ $# would otherwise look like the author has none. Offer a one-click
+ $# clear back to the full bibliography, with its real size.
+ $if filter_active and books_count == 0:
+ $ clear_url = changequery(has_fulltext=None, public_scan=None, print_disabled=None, language=None, mode=None, page=None)
+ $# The js-clear-reading-prefs class lets SearchFilterBar.js wipe the
+ $# stored global preference on click — otherwise it'd be re-inherited
+ $# on reload and zero the page again. Works without JS too (no
+ $# inheritance happens without JS).
+
+ $_('No matching works by this author with the current filters.')
+
+ $_('Show all %(count)s works', count=commify(page.get_work_count()))?
+
$for doc in books.docs:
$:macros.SearchResultsWork(doc, show_librarian_extras=show_librarian_extras, include_dropper=True, seq_index=loop.index0)
diff --git a/openlibrary/templates/work_search.html b/openlibrary/templates/work_search.html
index 3c6140e42f0..470935bba1e 100644
--- a/openlibrary/templates/work_search.html
+++ b/openlibrary/templates/work_search.html
@@ -46,12 +46,16 @@ $_("Search Books")
$# rendered into the sublabel here, so it's present on first paint with no
$# async flash-in or layout shift. Empty when there's nothing to count.
$ readable_sublabel = commify(readable_count) if readable_count is not None else ''
+ $# Render the on/off state server-side so the toggle paints in its final
+ $# position — without `checked` it starts off and the client-side seed
+ $# animates it on after load.
{
@@ -297,9 +302,9 @@ describe('recent searches (localStorage)', () => {
});
});
-describe('readStoredLanguages (sessionStorage)', () => {
+describe('readStoredLanguages (localStorage)', () => {
beforeEach(() => {
- sessionStorage.clear();
+ localStorage.clear();
jest.restoreAllMocks();
});
@@ -308,31 +313,80 @@ describe('readStoredLanguages (sessionStorage)', () => {
});
test('returns the stored array of codes', () => {
- sessionStorage.setItem(SS_LANGUAGES_KEY, JSON.stringify(['eng', 'fre']));
+ localStorage.setItem(LS_LANGUAGES_KEY, JSON.stringify(['eng', 'fre']));
expect(readStoredLanguages()).toEqual(['eng', 'fre']);
});
test('returns [] when the parsed value is not an array', () => {
- sessionStorage.setItem(SS_LANGUAGES_KEY, JSON.stringify('eng'));
+ localStorage.setItem(LS_LANGUAGES_KEY, JSON.stringify('eng'));
expect(readStoredLanguages()).toEqual([]);
});
test('drops non-string entries so a corrupt value cannot leak a bogus filter', () => {
- sessionStorage.setItem(SS_LANGUAGES_KEY, JSON.stringify(['eng', 1, null, 'spa']));
+ localStorage.setItem(LS_LANGUAGES_KEY, JSON.stringify(['eng', 1, null, 'spa']));
expect(readStoredLanguages()).toEqual(['eng', 'spa']);
});
test('returns [] on unparseable JSON', () => {
- sessionStorage.setItem(SS_LANGUAGES_KEY, '{nope');
+ localStorage.setItem(LS_LANGUAGES_KEY, '{nope');
expect(readStoredLanguages()).toEqual([]);
});
- test('returns [] when sessionStorage.getItem throws (private browsing)', () => {
+ test('returns [] when localStorage.getItem throws (private browsing)', () => {
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('denied'); });
expect(readStoredLanguages()).toEqual([]);
});
});
+describe('availability/language preference round-trip (localStorage)', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ jest.restoreAllMocks();
+ });
+
+ test('readStoredAvailability falls back to the default when unset', () => {
+ expect(readStoredAvailability()).toBe(DEFAULT_AVAILABILITY);
+ });
+
+ test('writeStoredAvailability round-trips a value', () => {
+ writeStoredAvailability('readable');
+ expect(localStorage.getItem(LS_AVAILABILITY_KEY)).toBe('readable');
+ expect(readStoredAvailability()).toBe('readable');
+ });
+
+ test('writeStoredAvailability coerces a falsy value to the default', () => {
+ writeStoredAvailability('');
+ expect(localStorage.getItem(LS_AVAILABILITY_KEY)).toBe(DEFAULT_AVAILABILITY);
+ });
+
+ test('writeStoredLanguages round-trips through readStoredLanguages', () => {
+ writeStoredLanguages(['eng', 'fre']);
+ expect(localStorage.getItem(LS_LANGUAGES_KEY)).toBe(JSON.stringify(['eng', 'fre']));
+ expect(readStoredLanguages()).toEqual(['eng', 'fre']);
+ });
+
+ test('writeStoredLanguages treats a nullish list as empty', () => {
+ writeStoredLanguages(null);
+ expect(readStoredLanguages()).toEqual([]);
+ });
+
+ test('the preference persists across a simulated session boundary', () => {
+ // localStorage (not sessionStorage) is what makes availability + language
+ // durable across visits — the whole point of the Phase 0 migration.
+ writeStoredAvailability('readable');
+ writeStoredLanguages(['spa']);
+ // A new page load reads the same backing store; nothing is cleared.
+ expect(readStoredAvailability()).toBe('readable');
+ expect(readStoredLanguages()).toEqual(['spa']);
+ });
+
+ test('write helpers are no-ops when localStorage.setItem throws', () => {
+ jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('denied'); });
+ expect(() => writeStoredAvailability('readable')).not.toThrow();
+ expect(() => writeStoredLanguages(['eng'])).not.toThrow();
+ });
+});
+
describe('readableEditionLanguages', () => {
const opts = [
{ value: 'fre', label: 'French' },