@@ -842,11 +1042,33 @@ export class OlBookActions extends LitElement {
if (changed.has('_pane')) this._syncTrackHeight();
}
+ connectedCallback() {
+ super.connectedCallback();
+ document.addEventListener('ol-list-created', this._onListCreatedElsewhere);
+ }
+
disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver?.disconnect();
+ document.removeEventListener('ol-list-created', this._onListCreatedElsewhere);
}
+ /**
+ * A list created elsewhere on the page — a sibling popover or the legacy
+ * dropper — folded into this popover's pane so it stays honest without a
+ * refetch. Legacy creations also drop the shared cache: popovers that have
+ * not loaded yet must not resolve from a promise that predates the list.
+ */
+ _onListCreatedElsewhere = (e) => {
+ if (e.target === this) return;
+ const { key, name, seedKey } = e.detail || {};
+ if (!key) return;
+ if (e.target?.tagName !== 'OL-BOOK-ACTIONS') resetListsCache();
+ if (this._lists && !(key in this._lists)) {
+ this._lists = { [key]: { listName: name, members: seedKey ? [seedKey] : [] }, ...this._lists };
+ }
+ };
+
/** Size the track to the active pane so the panel doesn't stretch to the taller one. */
_syncTrackHeight() {
const pane = this.shadowRoot.querySelector(`.pane:nth-child(${this._paneIndex + 1})`);
@@ -902,12 +1124,28 @@ export class OlBookActions extends LitElement {
// ── Shelves ──────────────────────────────────────────────
async _onShelfClick(shelfId) {
+ const previous = this.shelf;
+ // Already Read leads to the date pane — that is what its chevron says,
+ // and it is the only way to change a date once given. Coming off the
+ // shelf is the "Remove from shelf" row's job.
+ if (shelfId === SHELF.ALREADY_READ && previous === SHELF.ALREADY_READ) {
+ return this._openCheckIn();
+ }
+ return this._postShelf(shelfId);
+ }
+
+ /** Takes the book off whichever shelf it is on. Also what the main button does. */
+ _removeFromShelf() {
+ if (this.shelf) return this._postShelf(this.shelf);
+ }
+
+ /** Posting the current shelf toggles it off server-side; any other shelf moves the book. */
+ async _postShelf(shelfId) {
const previous = this.shelf;
const removing = previous === shelfId;
this.shelf = removing ? null : shelfId;
this._busy = true;
try {
- // Posting the current shelf toggles it off server-side.
await setShelf(this.book.key, shelfId, { editionKey: this.book.editionKey });
trackEvent('ReadingLog', removing ? 'RemoveFromShelf' : SHELF_EVENT[shelfId]);
this._emitState();
@@ -952,16 +1190,25 @@ export class OlBookActions extends LitElement {
async _openCheckIn() {
this._pane = 'checkIn';
- this._pickingDate = false;
- this._date = { year: '', month: '', day: '' };
+ // A date the shortcuts cannot express would otherwise sit unseen
+ // behind a collapsed row, so the pane opens on it. Focus still lands
+ // on the first row: the reader is being shown their answer, not asked
+ // to retype it.
+ this._pickingDate = this._answeredBy === 'other';
+ // Seeded from the date already given, so "Other date" opens on it
+ // rather than making the reader re-enter what they are amending.
+ const [year = '', month = '', day = ''] = (this.readDate || '').split('-');
+ this._date = { year, month: month.replace(/^0/, ''), day: day.replace(/^0/, '') };
await this.updateComplete;
this.shadowRoot.querySelector(`.pane:nth-child(${PANES.indexOf('checkIn') + 1}) .row`)?.focus({ preventScroll: true });
}
- async _startPickingDate() {
- this._pickingDate = true;
+ /** Focus follows the disclosure: into the selects, and back to the row on collapse. */
+ async _toggleDatePicker() {
+ this._pickingDate = !this._pickingDate;
await this.updateComplete;
- this.shadowRoot.querySelector('.select.year')?.focus({ preventScroll: true });
+ const target = this._pickingDate ? '.select.year' : '.date-toggle';
+ this.shadowRoot.querySelector(target)?.focus({ preventScroll: true });
}
/** Clearing a coarser part clears the finer ones, which the selects disable. */
@@ -977,8 +1224,8 @@ export class OlBookActions extends LitElement {
return this._saveCheckIn({ year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() });
}
- _onThisYear() {
- return this._saveCheckIn({ year: new Date().getFullYear() });
+ _onYear(year) {
+ return this._saveCheckIn({ year });
}
_onSaveDate(e) {
@@ -996,8 +1243,13 @@ export class OlBookActions extends LitElement {
if (this._dateBusy) return;
this._dateBusy = true;
try {
- await setCheckIn(this.book.key, { ...date, editionKey: this.book.editionKey });
+ const saved = await setCheckIn(this.book.key, { ...date, editionKey: this.book.editionKey, eventId: this.eventId });
trackEvent('CheckInPrompt', date.day ? 'SetDateDay' : date.month ? 'SetDateMonth' : 'SetDateYear');
+ this.dispatchEvent(new CustomEvent('ol-book-check-in', {
+ bubbles: true,
+ composed: true,
+ detail: { key: this.book.key, date: partialDate(date), eventId: saved?.id ?? this.eventId ?? null },
+ }));
this._backToMain();
} catch (error) {
this._fail(error);
@@ -1082,11 +1334,16 @@ export class OlBookActions extends LitElement {
try {
const created = await createList(this.userKey, name, this._seedKey);
trackEvent('Lists', 'CreateList');
- // Prepend so the new list is visible immediately; the shared cache
- // is the same object, so sibling popovers see it too.
+ // Prepend so the new list is visible immediately. Sibling popovers
+ // and the legacy dropper hear about it through `ol-list-created`.
this._lists = { [created.key]: { listName: name, members: [this._seedKey] }, ...this._lists };
_listsPromise = Promise.resolve(this._lists);
this._creating = false;
+ this.dispatchEvent(new CustomEvent('ol-list-created', {
+ bubbles: true,
+ composed: true,
+ detail: { key: created.key, name, seedKey: this._seedKey },
+ }));
} catch (error) {
this._fail(error);
} finally {
diff --git a/openlibrary/components/lit/OlOptionsPopover.js b/openlibrary/components/lit/OlOptionsPopover.js
index 8c9a5e8e7b0..5f9dbb1e352 100644
--- a/openlibrary/components/lit/OlOptionsPopover.js
+++ b/openlibrary/components/lit/OlOptionsPopover.js
@@ -149,7 +149,7 @@ export class OlOptionsPopover extends FormAssociatedMixin(LitElement) {
width: 16px;
height: 16px;
margin: 2px 0 0;
- accent-color: var(--primary-blue);
+ accent-color: var(--color-primary);
cursor: pointer;
}
diff --git a/openlibrary/components/lit/OlSelectPopover.js b/openlibrary/components/lit/OlSelectPopover.js
index 061120776c3..4c6aaf00a43 100644
--- a/openlibrary/components/lit/OlSelectPopover.js
+++ b/openlibrary/components/lit/OlSelectPopover.js
@@ -239,7 +239,7 @@ export class OlSelectPopover extends FormAssociatedMixin(LitElement) {
width: 16px;
height: 16px;
margin: 0;
- accent-color: var(--primary-blue);
+ accent-color: var(--color-primary);
cursor: pointer;
}
diff --git a/openlibrary/components/lit/OlShelfButton.js b/openlibrary/components/lit/OlShelfButton.js
index 9edb39fc6d3..64a198caad9 100644
--- a/openlibrary/components/lit/OlShelfButton.js
+++ b/openlibrary/components/lit/OlShelfButton.js
@@ -49,6 +49,9 @@ const SHELF_LABEL = {
* @prop {Number} shelf - Current shelf id (1–4), or null when on none
* @prop {Number} rating - Current rating (1–5), or null. Passed through to the
* popover and echoed on every state change
+ * @prop {String} readDate - Check-in date, whole or partial, shown on the
+ * popover's Already Read row. Applied by the surface, like shelf and rating
+ * @prop {Number} eventId - Id of that check-in, so editing the date amends it
* @prop {String} userKey - "/people/
" when signed in; empty sends the
* visitor to log in instead of opening the popover
* @prop {String} placement - ol-popover placement for the actions panel;
@@ -59,6 +62,8 @@ const SHELF_LABEL = {
*
* @fires ol-book-state-change - The shelf or rating changed, optimistically or
* rolled back. detail: { key, shelf, rating }
+ * @fires ol-book-check-in - Re-fired from the popover when a finish date is
+ * saved. detail: { key, date, eventId }
*/
export class OlShelfButton extends LitElement {
static properties = {
@@ -68,6 +73,8 @@ export class OlShelfButton extends LitElement {
bookTitle: { type: String, attribute: 'book-title' },
shelf: { type: Number },
rating: { type: Number },
+ readDate: { type: String, attribute: 'read-date' },
+ eventId: { type: Number, attribute: 'event-id' },
userKey: { type: String, attribute: 'user-key' },
placement: { type: String },
labels: { type: Object },
@@ -88,17 +95,28 @@ export class OlShelfButton extends LitElement {
/* ── Split variant ────────────────────────────────────────── */
+ /* Same surface as ol-button: raised shadow plus the inset specular top edge. */
.split {
display: flex;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-button);
overflow: hidden;
background: var(--white);
+ --control-highlight-strength: 35%;
+ box-shadow:
+ var(--box-shadow-raised),
+ inset 0 1px 0
+ color-mix(
+ in srgb,
+ var(--white) var(--control-highlight-strength),
+ var(--control-surface)
+ );
}
.split--on {
border-color: var(--color-control-selected-border);
background: var(--color-control-selected-bg);
+ --control-surface: var(--color-control-selected-surface);
}
.main,
@@ -113,7 +131,7 @@ export class OlShelfButton extends LitElement {
color: var(--color-text);
font-family: var(--font-family-button);
font-size: var(--font-size-body-medium);
- font-weight: 600;
+ line-height: var(--line-height-control);
cursor: pointer;
}
@@ -148,9 +166,32 @@ export class OlShelfButton extends LitElement {
color: var(--color-link);
}
- .main:hover,
- .more:hover {
- background: var(--color-hover-overlay);
+ /* Hover mirrors ol-button secondary/selected: the hovered half takes the
+ fill, and the outline darkens in step so the shape reads as one. */
+ @media (hover: hover) and (pointer: fine) {
+ .main:hover,
+ .more:hover {
+ background: var(--color-control-hover);
+ }
+
+ .split:has(.main:hover, .more:hover) {
+ border-color: var(--color-border-muted);
+ --control-surface: var(--color-control-hover);
+ }
+
+ .split--on .main:hover,
+ .split--on .more:hover {
+ background: var(--color-control-selected-bg-hover);
+ }
+
+ .split--on:has(.main:hover, .more:hover) {
+ border-color: var(--color-control-selected-border-hover);
+ --control-surface: var(--color-control-selected-surface-hover);
+ }
+
+ .split--on:has(.main:hover, .more:hover) .more {
+ border-left-color: var(--color-control-selected-border-hover);
+ }
}
.main:focus-visible,
@@ -265,6 +306,8 @@ export class OlShelfButton extends LitElement {
.book=${{ key: this.workKey, title: this.bookTitle, editionKey: this.editionKey }}
.shelf=${this.shelf}
.rating=${this.rating}
+ .readDate=${this.readDate}
+ .eventId=${this.eventId}
.labels=${this.labels}
user-key=${this.userKey}
placement=${ifDefined(this.placement)}
diff --git a/openlibrary/components/lit/utils/books-api.js b/openlibrary/components/lit/utils/books-api.js
index c8f0626b77a..238ace10a5c 100644
--- a/openlibrary/components/lit/utils/books-api.js
+++ b/openlibrary/components/lit/utils/books-api.js
@@ -74,14 +74,19 @@ export const EVENT = Object.freeze({ START: 1, UPDATE: 2, FINISH: 3 });
* POST /works/OL..W/check-ins — when the reader finished the book.
* `month` and `day` are optional: a year alone, or a year and month, are both
* valid check-ins, which is what lets the UI offer "in 2026".
+ *
+ * `eventId` edits that check-in in place. Without it the server records another
+ * one, which would count as a second book finished — so pass it whenever the
+ * reader is changing a date they already gave.
*/
-export function setCheckIn(workKey, { year, month = null, day = null, editionKey } = {}) {
+export function setCheckIn(workKey, { year, month = null, day = null, editionKey, eventId = null } = {}) {
return request(`/works/${olid(workKey)}/check-ins`, json({
event_type: EVENT.FINISH,
year,
month,
day,
edition_key: editionKey || null,
+ event_id: eventId || null,
}));
}
diff --git a/openlibrary/core/bookshelves_events.py b/openlibrary/core/bookshelves_events.py
index 50b34257531..22650c61baa 100644
--- a/openlibrary/core/bookshelves_events.py
+++ b/openlibrary/core/bookshelves_events.py
@@ -66,6 +66,24 @@ def get_latest_event_date(cls, username, work_id, event_type):
results = list(oldb.query(query, vars=data))
return results[0] if results else None
+ @classmethod
+ def get_latest_event_dates_for_works(cls, username: str, work_ids: list[int], event_type: int) -> dict[int, dict]:
+ """The most recent event of `event_type` per work, for a batch of works.
+
+ The single-work version costs a query each, which a page rendering
+ twenty search results cannot afford.
+ """
+ if not work_ids:
+ return {}
+ oldb = db.get_db()
+ data = {"username": username, "work_ids": work_ids, "event_type": event_type}
+ query = (
+ f"SELECT DISTINCT ON (work_id) work_id, id, event_date FROM {cls.TABLENAME}"
+ " WHERE username=$username AND work_id IN $work_ids AND event_type=$event_type"
+ " ORDER BY work_id, event_date DESC"
+ )
+ return {row.work_id: row for row in oldb.query(query, vars=data)}
+
@classmethod
def get_user_yearly_read_counts(cls, username: str) -> list[tuple[int, int]]:
"""Returns books read by year for a given user."""
diff --git a/openlibrary/i18n/messages.pot b/openlibrary/i18n/messages.pot
index a4dd81bc4e6..7906d4b3892 100644
--- a/openlibrary/i18n/messages.pot
+++ b/openlibrary/i18n/messages.pot
@@ -718,7 +718,7 @@ msgstr ""
msgid "Now"
msgstr ""
-#: admin/graphs.html admin/index.html my_books/check_ins/check_in_form.html my_books/check_ins/check_in_prompt.html trending.html
+#: admin/graphs.html admin/index.html my_books/book_actions_i18n.html my_books/check_ins/check_in_form.html my_books/check_ins/check_in_prompt.html trending.html
msgid "Today"
msgstr ""
@@ -751,13 +751,13 @@ msgid "Earliest trending data is from October 2017"
msgstr ""
#. Label for the reading log shelf for books the user plans to read in the future (bookshelf ID 1). Used as a button label and shelf name.
-#: account/mybooks.html account/sidebar.html my_books/dropdown_content.html my_books/primary_action.html search/sort_options.html trending.html
+#: account/mybooks.html account/sidebar.html my_books/book_actions_i18n.html my_books/dropdown_content.html my_books/primary_action.html search/sort_options.html trending.html
msgid "Want to Read"
msgstr ""
#. Display name for the "currently-reading" reading log shelf used in page headings and breadcrumbs.
#. Label for the reading log shelf for books the user is actively reading (bookshelf ID 2). Used as a button label and shelf name.
-#: account/mybooks.html account/readinglog_shelf_name.html account/sidebar.html my_books/dropdown_content.html my_books/primary_action.html search/sort_options.html trending.html
+#: account/mybooks.html account/readinglog_shelf_name.html account/sidebar.html my_books/book_actions_i18n.html my_books/dropdown_content.html my_books/primary_action.html search/sort_options.html trending.html
msgid "Currently Reading"
msgstr ""
@@ -768,7 +768,7 @@ msgstr ""
#. Display name for the "stopped-reading" reading log shelf used in page headings and breadcrumbs.
#. Label for the reading log shelf for books the user stopped reading before finishing (bookshelf ID 4). Used as a button label and shelf name.
#. Label for the reading log shelf for books the user stopped reading (bookshelf ID 4). Used as a button label and shelf name.
-#: account/mybooks.html account/readinglog_shelf_name.html account/sidebar.html my_books/dropdown_content.html my_books/primary_action.html search/sort_options.html trending.html
+#: account/mybooks.html account/readinglog_shelf_name.html account/sidebar.html my_books/book_actions_i18n.html my_books/dropdown_content.html my_books/primary_action.html search/sort_options.html trending.html
msgid "Stopped Reading"
msgstr ""
@@ -1293,7 +1293,7 @@ msgstr ""
#. Display name for the "already-read" reading log shelf used in page headings and breadcrumbs.
#. Label for the reading log shelf for books the user has finished reading (bookshelf ID 3). Used as a button label and shelf name.
-#: account/mybooks.html account/readinglog_shelf_name.html account/sidebar.html my_books/dropdown_content.html my_books/primary_action.html openlibrary/plugins/upstream/mybooks.py search/sort_options.html
+#: account/mybooks.html account/readinglog_shelf_name.html account/sidebar.html my_books/book_actions_i18n.html my_books/dropdown_content.html my_books/primary_action.html openlibrary/plugins/upstream/mybooks.py search/sort_options.html
msgid "Already Read"
msgstr ""
@@ -1395,7 +1395,7 @@ msgstr ""
msgid "Yes! Please!"
msgstr ""
-#: EditButtons.html account/notifications.html account/privacy.html admin/block.html admin/spamwords.html covers/manage.html
+#: EditButtons.html account/notifications.html account/privacy.html admin/block.html admin/spamwords.html covers/manage.html my_books/book_actions_i18n.html
msgid "Save"
msgstr ""
@@ -1652,7 +1652,7 @@ msgstr ""
msgid "See All"
msgstr ""
-#: account/sidebar.html type/list/edit.html type/series/edit.html
+#: account/sidebar.html my_books/book_actions_i18n.html type/list/edit.html type/series/edit.html
msgid "Create a list"
msgstr ""
@@ -3113,7 +3113,7 @@ msgstr ""
msgid " by %(name)s"
msgstr ""
-#: books/daisy.html
+#: books/daisy.html my_books/book_actions_i18n.html
msgid "Back"
msgstr ""
@@ -5153,6 +5153,110 @@ msgstr ""
msgid "comments"
msgstr ""
+#: my_books/book_actions_i18n.html
+msgid "Remove from shelf"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+#, python-format
+msgid "Actions for %(title)s"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+#, python-format
+msgid "Save %(title)s to your reading log"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+#, python-format
+msgid "%(title)s is on your reading log"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+#, python-format
+msgid "More options for %(title)s"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "Rate this book"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+#, python-format
+msgid "Rate %(rating)s of 5"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "Clear rating"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "Add to list"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "List name"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "Create"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "Filter lists…"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "You have no lists yet."
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "No lists match."
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "Loading lists…"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+#, python-format
+msgid "%(count)s items"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+#, python-format
+msgid "In %(count)s of your lists"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "Something went wrong. Please try again."
+msgstr ""
+
+#: my_books/book_actions_i18n.html my_books/check_ins/check_in_prompt.html
+msgid "When did you finish this book?"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+#, python-format
+msgid "In %(year)s"
+msgstr ""
+
+#: my_books/book_actions_i18n.html
+msgid "Other date"
+msgstr ""
+
+#: my_books/book_actions_i18n.html my_books/check_ins/check_in_form.html
+msgid "Year"
+msgstr ""
+
+#: my_books/book_actions_i18n.html my_books/check_ins/check_in_form.html
+msgid "Month"
+msgstr ""
+
+#: my_books/book_actions_i18n.html my_books/check_ins/check_in_form.html
+msgid "Day"
+msgstr ""
+
#: my_books/dropdown_content.html
msgid "Remove From Shelf"
msgstr ""
@@ -5262,26 +5366,14 @@ msgstr ""
msgid "Year:"
msgstr ""
-#: my_books/check_ins/check_in_form.html
-msgid "Year"
-msgstr ""
-
#: my_books/check_ins/check_in_form.html
msgid "Month:"
msgstr ""
-#: my_books/check_ins/check_in_form.html
-msgid "Month"
-msgstr ""
-
#: my_books/check_ins/check_in_form.html
msgid "Day:"
msgstr ""
-#: my_books/check_ins/check_in_form.html
-msgid "Day"
-msgstr ""
-
#: my_books/check_ins/check_in_form.html
msgid "Delete Event"
msgstr ""
@@ -5299,10 +5391,6 @@ msgstr ""
msgid "Read %(date)s "
msgstr ""
-#: my_books/check_ins/check_in_prompt.html
-msgid "When did you finish this book?"
-msgstr ""
-
#: my_books/check_ins/check_in_prompt.html
msgid "Other"
msgstr ""
diff --git a/openlibrary/macros/SearchResultsWork.html b/openlibrary/macros/SearchResultsWork.html
index 38b32683f10..a4fb0b85dba 100644
--- a/openlibrary/macros/SearchResultsWork.html
+++ b/openlibrary/macros/SearchResultsWork.html
@@ -1,4 +1,4 @@
-$def with (doc, decorations=None, cta=True, availability=None, extra=None, attrs=None, rating=None, highlighting=None, show_librarian_extras=False, include_dropper=False, blur=False, footer=None, seq_index=None)
+$def with (doc, decorations=None, cta=True, availability=None, extra=None, attrs=None, rating=None, highlighting=None, show_librarian_extras=False, include_dropper=False, blur=False, footer=None, seq_index=None, use_shelf_button=False, reading_state=None, shelf_labels=None, hide_rating=False)
$code:
max_rendered_authors = 9
@@ -268,7 +268,10 @@
$ edition_key = doc.get('edition_key') and doc.get('edition_key')[0]
$if edition_key:
$ edition_key = '/books/%s' % edition_key
- $:render_template('my_books/dropper', doc, edition_key=edition_key, async_load=True)
+ $if use_shelf_button:
+ $:render_template('my_books/shelf_button', doc, edition_key=edition_key, reading_state=reading_state, labels=shelf_labels, hide_rating=hide_rating)
+ $else:
+ $:render_template('my_books/dropper', doc, edition_key=edition_key, async_load=True)
$if rating:
$:rating
diff --git a/openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js b/openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
index 63bcf111251..621984fac9c 100644
--- a/openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
+++ b/openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
@@ -84,9 +84,11 @@ export default class SelectionManager {
* @param {MouseEvent & { currentTarget: HTMLElement }} clickEvent
*/
processClick(clickEvent) {
- // If there is text selection or the click is on a link that isn't a select handle, don't do anything
+ // If there is text selection or the click is on a link that isn't a select handle, don't do anything.
+ // `ol-shelf-button` is named explicitly: a click inside a shadow root
+ // retargets to the host, so the it wraps is invisible to closest().
if ((!clickEvent.shiftKey && window.getSelection()?.toString() !== '') ||
- ($(clickEvent.target).closest('a, button, details').length > 0 &&
+ ($(clickEvent.target).closest('a, button, details, ol-shelf-button').length > 0 &&
$(clickEvent.target).not('.ile-select-handle').length > 0)) return;
const el = clickEvent.currentTarget;
diff --git a/openlibrary/plugins/openlibrary/js/index.js b/openlibrary/plugins/openlibrary/js/index.js
index 0f59ed2113e..ce759cd68a2 100644
--- a/openlibrary/plugins/openlibrary/js/index.js
+++ b/openlibrary/plugins/openlibrary/js/index.js
@@ -418,6 +418,16 @@ jQuery(function() {
});
}
+ // is stateless: the page owns the book state it emits,
+ // and the check-in prompt has to follow the shelf the button just set.
+ const shelfButtons = document.querySelectorAll('ol-shelf-button[work-key]');
+ if (shelfButtons.length) {
+ import(/* webpackChunkName: "my-books" */ './my-books')
+ .then((module) => {
+ module.initShelfButtons(shelfButtons);
+ });
+ }
+
// TODO: Make these selectors a consistent interface
const $dialogs = $('.dialog--open,.dialog--close,#noMaster,#confirmMerge,#leave-waitinglist-dialog,#bookPreview');
if ($dialogs.length) {
diff --git a/openlibrary/plugins/openlibrary/js/my-books/CreateListForm.js b/openlibrary/plugins/openlibrary/js/my-books/CreateListForm.js
index 76947bfe26f..87a4b7e500c 100644
--- a/openlibrary/plugins/openlibrary/js/my-books/CreateListForm.js
+++ b/openlibrary/plugins/openlibrary/js/my-books/CreateListForm.js
@@ -81,7 +81,8 @@ export class CreateListForm {
*/
async createNewList() {
// Construct seed object for first list item:
- const listTitle = websafe(this.listTitleInput.value);
+ const rawListTitle = this.listTitleInput.value;
+ const listTitle = websafe(rawListTitle);
const listDescription = websafe(this.listDescriptionInput.value);
const openDropper = myBooksStore.getOpenDropper();
@@ -103,6 +104,13 @@ export class CreateListForm {
// Update all droppers with new list data
this.updateDroppersOnListCreation(data['key'], listTitle, data['key']);
+ // Any popovers on the page cache the list
+ // set; tell them so their pane shows the new list too. Raw
+ // name: the popover renders through Lit, which escapes itself.
+ document.dispatchEvent(new CustomEvent('ol-list-created', {
+ detail: { key: data['key'], name: rawListTitle, seedKey: seed }
+ }));
+
// Clear list creation form fields, nullify seed
this.resetForm();
})
diff --git a/openlibrary/plugins/openlibrary/js/my-books/MyBooksDropper/CheckInComponents.js b/openlibrary/plugins/openlibrary/js/my-books/MyBooksDropper/CheckInComponents.js
index cedfa9bbb60..703937bda85 100644
--- a/openlibrary/plugins/openlibrary/js/my-books/MyBooksDropper/CheckInComponents.js
+++ b/openlibrary/plugins/openlibrary/js/my-books/MyBooksDropper/CheckInComponents.js
@@ -88,7 +88,7 @@ export class CheckInComponents {
initialize() {
this.checkInPrompt.initialize();
- this.checkInPrompt.getRootElement().addEventListener('submit-check-in', (event) => {
+ this.checkInPrompt.getRootElement()?.addEventListener('submit-check-in', (event) => {
const year = event.detail.year;
const month = event.detail.month;
const day = event.detail.day;
@@ -342,17 +342,22 @@ export class CheckInComponents {
* Adds functionality to the component containing the "When did you finish this book?"
* prompt.
*
+ * Surfaces that ask for the date somewhere else render the container without a
+ * prompt, so every method here has to survive a missing root element.
+ *
* @class
*/
class CheckInPrompt {
/**
- * @param {HTMLElement} checkInPrompt
+ * @param {HTMLElement|null} checkInPrompt
*/
constructor(checkInPrompt) {
this.rootElem = checkInPrompt;
}
initialize() {
+ if (!this.rootElem) return;
+
const yearLink = this.rootElem.querySelector('.prompt-current-year');
yearLink.addEventListener('click', () => {
// Get the current year
@@ -388,26 +393,26 @@ class CheckInPrompt {
day: day
}
});
- this.rootElem.dispatchEvent(submitEvent);
+ this.rootElem?.dispatchEvent(submitEvent);
}
/**
* Hides this check-in prompt.
*/
hide() {
- this.rootElem.classList.add('hidden');
+ this.rootElem?.classList.add('hidden');
}
/**
* Shows this check-in prompt.
*/
show() {
- this.rootElem.classList.remove('hidden');
+ this.rootElem?.classList.remove('hidden');
}
/**
* Returns reference to the root element of this check-in prompt.
- * @returns {HTMLElement}
+ * @returns {HTMLElement|null}
*/
getRootElement() {
return this.rootElem;
diff --git a/openlibrary/plugins/openlibrary/js/my-books/index.js b/openlibrary/plugins/openlibrary/js/my-books/index.js
index 1534af56ddb..b40782d1dcd 100644
--- a/openlibrary/plugins/openlibrary/js/my-books/index.js
+++ b/openlibrary/plugins/openlibrary/js/my-books/index.js
@@ -4,6 +4,9 @@ import myBooksStore from './store';
import { getListPartials } from '../lists/ListService';
import { ShowcaseItem, createActiveShowcaseItem, toggleActiveShowcaseItems } from '../lists/ShowcaseItem';
import { removeChildren } from '../utils';
+import { websafe } from '../jsdef';
+
+export { initShelfButtons } from './shelf-buttons';
// XXX : jsdoc
// XXX : decompose
@@ -40,6 +43,19 @@ export function initMyBooksAffordances(dropperElements, showcaseElements) {
myBooksStore.setUserKey(userKey);
myBooksStore.setDroppers(droppers);
+ // A mixed page: a list created inside an popover has to
+ // appear in these droppers too. CreateListForm's own creations dispatch on
+ // `document` and already update the droppers, so only bubbled events —
+ // whose target is the popover — are handled here.
+ document.addEventListener('ol-list-created', (e) => {
+ if (e.target === document) return;
+ // The name is patron text and the row is built with innerHTML.
+ const listTitle = websafe(e.detail.name);
+ for (const dropper of myBooksStore.getDroppers()) {
+ dropper.readingLists.onListCreationSuccess(e.detail.key, listTitle, false, '');
+ }
+ });
+
getListPartials()
.then(response => response.json())
.then((data) => {
diff --git a/openlibrary/plugins/openlibrary/js/my-books/shelf-buttons.js b/openlibrary/plugins/openlibrary/js/my-books/shelf-buttons.js
new file mode 100644
index 00000000000..43609a97721
--- /dev/null
+++ b/openlibrary/plugins/openlibrary/js/my-books/shelf-buttons.js
@@ -0,0 +1,65 @@
+/**
+ * Owns book state for a page of ``s.
+ *
+ * The buttons are stateless by contract: they never write their own `shelf`,
+ * `rating` or read date, they emit `ol-book-state-change` — optimistically on
+ * click, and again with the old value if the write fails — and the surface
+ * applies it. Server-rendering the attributes only supplies the opening state,
+ * so without this the label stops matching the server after the first change.
+ *
+ * Applying it centrally is also what keeps two buttons for the same work in
+ * step, including the finish date the popover shows on its Already Read row.
+ *
+ * @module my-books/shelf-buttons
+ */
+
+/** "/works/OL1W" (or "OL1W") → "OL1W". */
+function olid(key) {
+ return (key || '').split('/').pop();
+}
+
+/**
+ * @param {NodeList|Array} shelfButtons
+ */
+export function initShelfButtons(shelfButtons) {
+ /** @type {Map} */
+ const buttonsByWork = new Map();
+
+ for (const button of shelfButtons) {
+ const workOlid = olid(button.getAttribute('work-key'));
+ if (!workOlid) continue;
+
+ if (!buttonsByWork.has(workOlid)) buttonsByWork.set(workOlid, []);
+ buttonsByWork.get(workOlid).push(button);
+ }
+
+ if (!buttonsByWork.size) return;
+
+ /** @param {string} key @param {(button: HTMLElement) => void} apply */
+ function forWork(key, apply) {
+ for (const button of buttonsByWork.get(olid(key)) || []) apply(button);
+ }
+
+ // The events are composed, so one document-level listener covers every
+ // button on the page.
+ document.addEventListener('ol-book-state-change', (event) => {
+ const { key, shelf, rating } = event.detail || {};
+ forWork(key, (button) => {
+ button.shelf = shelf ?? null;
+ button.rating = rating ?? null;
+ // Coming off a shelf deletes the check-ins server-side.
+ if (shelf === null || shelf === undefined) {
+ button.readDate = null;
+ button.eventId = null;
+ }
+ });
+ });
+
+ document.addEventListener('ol-book-check-in', (event) => {
+ const { key, date, eventId } = event.detail || {};
+ forWork(key, (button) => {
+ button.readDate = date ?? null;
+ button.eventId = eventId ?? null;
+ });
+ });
+}
diff --git a/openlibrary/plugins/upstream/mybooks.py b/openlibrary/plugins/upstream/mybooks.py
index 563c34f1d1c..ca4ea5beac6 100644
--- a/openlibrary/plugins/upstream/mybooks.py
+++ b/openlibrary/plugins/upstream/mybooks.py
@@ -16,7 +16,7 @@
)
from openlibrary.core.booknotes import Booknotes
from openlibrary.core.bookshelves import Bookshelves
-from openlibrary.core.bookshelves_events import BookshelvesEvents
+from openlibrary.core.bookshelves_events import BookshelfEvent, BookshelvesEvents
from openlibrary.core.cache import memcache_memoize
from openlibrary.core.follows import PubSub
from openlibrary.core.lending import (
@@ -25,6 +25,7 @@
)
from openlibrary.core.models import LoggedBooksData, User
from openlibrary.core.observations import Observations, convert_observation_ids
+from openlibrary.core.ratings import Ratings
from openlibrary.i18n import gettext as _
from openlibrary.plugins.openlibrary.home import caching_prethread
from openlibrary.plugins.upstream.utils import is_safe_redirect
@@ -326,6 +327,47 @@ def get_patrons_work_read_status(username: str, work_key: str) -> int | None:
return status_id
+@public
+def get_patrons_reading_states(work_keys: list[str]) -> dict[str, dict]:
+ """Shelf, rating and check-in presence for a batch of works.
+
+ `my_books/dropper` resolves these one work at a time, so a page of twenty
+ search results pays forty round-trips before it renders. `SearchResultsWork`
+ asks once instead and hands each `` its state as attributes.
+
+ Returns `{work_key: {"shelf", "rating", "last_read_date", "event_id"}}`,
+ with only the works that have some state present.
+ """
+ user = accounts.get_current_user()
+ if not user or not work_keys:
+ return {}
+
+ username = user.key.split("/")[-1]
+ ids_by_key = {key: int(extract_numeric_id_from_olid(key)) for key in work_keys if key}
+ if not ids_by_key:
+ return {}
+ work_ids = list(ids_by_key.values())
+
+ shelves = {row.work_id: row.bookshelf_id for row in Bookshelves.get_users_read_status_of_works(username, work_ids)}
+ ratings = Ratings.get_users_ratings_of_works(username, work_ids)
+ check_ins = BookshelvesEvents.get_latest_event_dates_for_works(username, work_ids, BookshelfEvent.FINISH)
+
+ states = {}
+ for key, work_id in ids_by_key.items():
+ shelf = shelves.get(work_id)
+ rating = ratings.get(work_id)
+ check_in = check_ins.get(work_id)
+ if shelf or rating or check_in:
+ states[key] = {
+ "shelf": shelf,
+ "rating": rating,
+ # The check-in prompt needs both to render its existing date.
+ "last_read_date": check_in["event_date"] if check_in else None,
+ "event_id": check_in["id"] if check_in else None,
+ }
+ return states
+
+
@public
class MyBooksTemplate:
# Reading log shelves
diff --git a/openlibrary/plugins/upstream/tests/test_mybooks_reading_states.py b/openlibrary/plugins/upstream/tests/test_mybooks_reading_states.py
new file mode 100644
index 00000000000..0cb9a699e3b
--- /dev/null
+++ b/openlibrary/plugins/upstream/tests/test_mybooks_reading_states.py
@@ -0,0 +1,77 @@
+"""Tests for the batched reading state behind ``.
+
+The point of the helper is that a page of results costs three queries instead of
+two per result, so what matters is that it asks once with every id and folds the
+three answers back onto the right work.
+"""
+
+from unittest.mock import patch
+
+import web
+
+from openlibrary.plugins.upstream.mybooks import get_patrons_reading_states
+
+MODULE = "openlibrary.plugins.upstream.mybooks"
+
+
+def _patches(shelves=None, ratings=None, check_ins=None):
+ return (
+ patch(f"{MODULE}.accounts.get_current_user", return_value=web.storage(key="/people/tester")),
+ patch(f"{MODULE}.Bookshelves.get_users_read_status_of_works", return_value=shelves or []),
+ patch(f"{MODULE}.Ratings.get_users_ratings_of_works", return_value=ratings or {}),
+ patch(f"{MODULE}.BookshelvesEvents.get_latest_event_dates_for_works", return_value=check_ins or {}),
+ )
+
+
+def _run(work_keys, **kwargs):
+ user, shelves, ratings, check_ins = _patches(**kwargs)
+ with user, shelves as s, ratings as r, check_ins as c:
+ return get_patrons_reading_states(work_keys), (s, r, c)
+
+
+class TestGetPatronsReadingStates:
+ def test_signed_out_asks_for_nothing(self):
+ with patch(f"{MODULE}.accounts.get_current_user", return_value=None):
+ assert get_patrons_reading_states(["/works/OL1W"]) == {}
+
+ def test_no_works_asks_for_nothing(self):
+ with patch(f"{MODULE}.accounts.get_current_user", return_value=web.storage(key="/people/tester")):
+ assert get_patrons_reading_states([]) == {}
+
+ def test_one_batched_call_per_source(self):
+ _, (shelves, ratings, check_ins) = _run(["/works/OL1W", "/works/OL2W"])
+ shelves.assert_called_once_with("tester", [1, 2])
+ ratings.assert_called_once_with("tester", [1, 2])
+ assert check_ins.call_args[0][:2] == ("tester", [1, 2])
+
+ def test_folds_all_three_onto_the_right_work(self):
+ states, _ = _run(
+ ["/works/OL1W", "/works/OL2W", "/works/OL3W"],
+ shelves=[web.storage(work_id=1, bookshelf_id=3)],
+ ratings={2: 5},
+ check_ins={1: {"id": 7, "event_date": "2026-05-01"}},
+ )
+ assert states["/works/OL1W"] == {
+ "shelf": 3,
+ "rating": None,
+ "last_read_date": "2026-05-01",
+ "event_id": 7,
+ }
+ assert states["/works/OL2W"]["rating"] == 5
+ assert states["/works/OL2W"]["last_read_date"] is None
+
+ def test_works_with_no_state_are_left_out(self):
+ states, _ = _run(
+ ["/works/OL1W", "/works/OL2W"],
+ shelves=[web.storage(work_id=1, bookshelf_id=1)],
+ )
+ assert "/works/OL2W" not in states
+
+ def test_a_check_in_alone_is_state_enough(self):
+ # The prompt still has a date to show even when the book sits on no
+ # shelf and is unrated, so the work has to survive the filter.
+ states, _ = _run(
+ ["/works/OL1W"],
+ check_ins={1: {"id": 7, "event_date": "2026-05-01"}},
+ )
+ assert states["/works/OL1W"]["last_read_date"] == "2026-05-01"
diff --git a/openlibrary/templates/design/components/book-actions.html.jinja b/openlibrary/templates/design/components/book-actions.html.jinja
index d63f9961a36..60df1646706 100644
--- a/openlibrary/templates/design/components/book-actions.html.jinja
+++ b/openlibrary/templates/design/components/book-actions.html.jinja
@@ -41,7 +41,7 @@
Requests. Shelves post to /works/OL…W/bookshelves.json, ratings to /works/OL…W/ratings.json, check-ins to /works/OL…W/check-ins, lists through /partials/MyBooksDropperLists.json (read) and the lists/seeds endpoints (write). A 401 on any of them redirects to login.
- Check-ins. Choosing Already Read slides in "When did you finish this book?" — because a year on its own is a valid check-in, "In 2026" is one tap, and "Other date" swaps in year/month/day selects where each enables the next. Only an explicit shelf choice opens it: rating a book moves it to Already Read server-side too, and interrupting that would turn one tap into two.
+ Check-ins. Choosing Already Read slides in "When did you finish this book?" — because a year on its own is a valid check-in, "In 2026" is one tap, and "Other date" discloses year/month/day selects underneath itself — each enabling the next — so the two one-tap answers stay on screen and pressing the row again closes them. Only an explicit shelf choice opens it: rating a book moves it to Already Read server-side too, and interrupting that would turn one tap into two. A date already recorded is marked in the pane rather than left to read as unanswered: whichever row holds it is aria-current, and when no shortcut can express it — a partial date, or any year not offered — "Other date" carries the date and opens on it, selects seeded, so the answer is not hidden behind a collapsed row. The date also rides on the Already Read row, which carries a chevron rather than a check because clicking it goes back to the pane to amend the date — pass event-id alongside read-date so amending edits that check-in instead of recording a second finish.
Panes. The track's width and slide are both derived from the PANES list, so a new pane is an entry there plus a _render* method. Escape in any sub-pane goes back to the main one; a second Escape closes the popover.
diff --git a/openlibrary/templates/my_books/book_actions_i18n.html b/openlibrary/templates/my_books/book_actions_i18n.html
new file mode 100644
index 00000000000..1e4c89322bf
--- /dev/null
+++ b/openlibrary/templates/my_books/book_actions_i18n.html
@@ -0,0 +1,53 @@
+$def with ()
+$code:
+ # Translated strings for ol-shelf-button and the ol-book-actions popover it
+ # opens. The components merge this over their own English DEFAULT_LABELS, so
+ # a key missing here is not fatal.
+ #
+ # The output is HTML-escaped by json_encode, so callers insert it raw:
+ # labels="$:render_template('my_books/book_actions_i18n')"
+ # Render it once per request and reuse the string -- every book on a page
+ # wants the same one.
+ # NOTE: Label for the reading log shelf for books the user plans to read in the future (bookshelf ID 1). Used as a button label and shelf name.
+ want_to_read = _("Want to Read")
+ # NOTE: Label for the reading log shelf for books the user is actively reading (bookshelf ID 2). Used as a button label and shelf name.
+ currently_reading = _("Currently Reading")
+ # NOTE: Label for the reading log shelf for books the user has finished reading (bookshelf ID 3). Used as a button label and shelf name.
+ already_read = _("Already Read")
+ # NOTE: Label for the reading log shelf for books the user stopped reading before finishing (bookshelf ID 4). Used as a button label and shelf name.
+ stopped_reading = _("Stopped Reading")
+ labels = {
+ "wantToRead": want_to_read,
+ "currentlyReading": currently_reading,
+ "alreadyRead": already_read,
+ "stoppedReading": stopped_reading,
+ "removeFromShelf": _("Remove from shelf"),
+ "actionsFor": _("Actions for %(title)s"),
+ "save": _("Save %(title)s to your reading log"),
+ "saved": _("%(title)s is on your reading log"),
+ "shelfMenu": _("More options for %(title)s"),
+ "rateThisBook": _("Rate this book"),
+ "rateStar": _("Rate %(rating)s of 5"),
+ "clearRating": _("Clear rating"),
+ "addToList": _("Add to list"),
+ "back": _("Back"),
+ "createList": _("Create a list"),
+ "listName": _("List name"),
+ "create": _("Create"),
+ "filterLists": _("Filter lists…"),
+ "noLists": _("You have no lists yet."),
+ "noMatchingLists": _("No lists match."),
+ "loadingLists": _("Loading lists…"),
+ "itemsInList": _("%(count)s items"),
+ "inLists": _("In %(count)s of your lists"),
+ "errorGeneric": _("Something went wrong. Please try again."),
+ "whenFinished": _("When did you finish this book?"),
+ "today": _("Today"),
+ "inYear": _("In %(year)s"),
+ "otherDate": _("Other date"),
+ "year": _("Year"),
+ "month": _("Month"),
+ "day": _("Day"),
+ "saveDate": _("Save"),
+ }
+$json_encode(labels)
diff --git a/openlibrary/templates/my_books/check_ins/check_in_prompt.html b/openlibrary/templates/my_books/check_ins/check_in_prompt.html
index 21b96ee192c..2a3e7ccce37 100644
--- a/openlibrary/templates/my_books/check_ins/check_in_prompt.html
+++ b/openlibrary/templates/my_books/check_ins/check_in_prompt.html
@@ -1,4 +1,4 @@
-$def with (work_key, read_status, edition_key=None, last_read_date=None, event_id=None)
+$def with (work_key, read_status, edition_key=None, last_read_date=None, event_id=None, show_prompt=True)
$# work_key : str : The work key
$# read_status : int | None : Number representing which shelf this work is on.
@@ -11,6 +11,9 @@
$# edition_key : str | None : The edition key
$# last_read_date : str | None : Date that the patron last read the book
$# event_id : str | None : ID of record for this event
+$# show_prompt : bool : Whether to ask for a date here. False where the surface
+$# asks somewhere else — asks inside its own popover — and
+$# only the "Read " display and its edit form are wanted.
$code:
work_olid = work_key.split('/')[-1]
@@ -38,16 +41,17 @@
- $ display_prompt = read_status == 3
-
diff --git a/static/css/components/searchResultItemCta.css b/static/css/components/searchResultItemCta.css
index 9bd62758746..472bafcf2ce 100644
--- a/static/css/components/searchResultItemCta.css
+++ b/static/css/components/searchResultItemCta.css
@@ -15,6 +15,13 @@
.searchResultItemCTA .generic-dropper-wrapper {
margin-top: 5px;
}
+
+/* Same slot the dropper occupied. The button is display:block and fills the
+ column, so the popover it opens lines up with the CTA above it. */
+.searchResultItemCTA .sri__shelf-button {
+ display: block;
+ margin-top: var(--spacing-stack-sm);
+}
/* @width-breakpoint-tablet */
@media only screen and (min-width: 768px) {
.searchResultItem .searchResultItemCTA {
diff --git a/tests/e2e/shelf-button.spec.ts b/tests/e2e/shelf-button.spec.ts
new file mode 100644
index 00000000000..797e38bddfe
--- /dev/null
+++ b/tests/e2e/shelf-button.spec.ts
@@ -0,0 +1,134 @@
+import { test, expect, type Page } from '@playwright/test';
+import { collectConsoleErrors } from './helpers';
+
+/**
+ * The shelf button on search results, end to end.
+ *
+ * Unit tests cover the component and the page-level state owner separately.
+ * What only shows up here is the seam between them: attributes are rendered by
+ * the server, the element upgrades later, and a click has to reach the server
+ * *and* come back to the label. Both bugs found while building this lived in
+ * that seam.
+ */
+
+const SEARCH_URL = '/search?q=the';
+const SHELF_BUTTON = 'ol-shelf-button[work-key]';
+
+async function gotoResults(page: Page) {
+ await page.goto(SEARCH_URL);
+ const count = await page.locator('.searchResultItem').count();
+ test.skip(count === 0, 'No Solr data indexed in this environment');
+ await page.locator(SHELF_BUTTON).first().waitFor({ timeout: 10_000 });
+}
+
+/**
+ * The dev-environment test patron. Skips when it is not provisioned.
+ * The username field is `type="email"`, so the bare username would fail HTML5
+ * validation and never submit.
+ */
+async function login(page: Page) {
+ await page.goto('/account/login');
+ await page.fill('input[name="username"]', 'openlibrary@example.com');
+ await page.fill('input[name="password"]', 'openlibrary');
+ await page.click('button[name="login"]');
+ await page.waitForLoadState('networkidle');
+ await page.goto(SEARCH_URL);
+ test.skip(
+ (await page.locator('ol-shelf-button[user-key]').count()) === 0,
+ 'No signed-in session — dev test patron unavailable',
+ );
+}
+
+test.describe('Shelf button on search results, signed out @smoke', () => {
+ test('renders without building a popover it cannot use', async ({ page }) => {
+ const errors = collectConsoleErrors(page);
+ await gotoResults(page);
+
+ const button = page.locator(SHELF_BUTTON).first();
+ await expect(button).toBeAttached();
+ expect(await button.getAttribute('user-key')).toBeNull();
+
+ // Signed out the trigger stands alone; the popover is never constructed.
+ const hasPopover = await button.evaluate(
+ el => !!el.shadowRoot?.querySelector('ol-book-actions'),
+ );
+ expect(hasPopover).toBe(false);
+ expect(errors()).toHaveLength(0);
+ });
+
+ test('a click goes to login and remembers the intent', async ({ page }) => {
+ await gotoResults(page);
+ await page.locator(SHELF_BUTTON).first()
+ .evaluate(el => (el.shadowRoot?.querySelector('.main') as HTMLElement)?.click());
+
+ await page.waitForURL(/\/account\/login/, { timeout: 10_000 });
+ const pending = (await page.context().cookies())
+ .find(c => c.name === 'pending_action');
+ expect(pending).toBeTruthy();
+ expect(JSON.parse(decodeURIComponent(pending!.value))).toMatchObject({ type: 'book' });
+ });
+});
+
+test.describe('Shelf button on search results, signed in', () => {
+ test('a shelf change reaches the server and returns to the label', async ({ page }) => {
+ await login(page);
+ await gotoResults(page);
+
+ const button = page.locator('ol-shelf-button[user-key]').first();
+ const workKey = await button.getAttribute('work-key');
+ const workOlid = workKey!.split('/').pop();
+
+ // Start from a known state rather than whatever the patron already had.
+ await page.evaluate(async (key) => {
+ await fetch(`/works/${key}/bookshelves.json`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'bookshelf_id=-1',
+ });
+ }, workOlid);
+ await page.reload();
+ await page.locator(SHELF_BUTTON).first().waitFor();
+
+ const target = page.locator('ol-shelf-button[user-key]').first();
+ await target.evaluate(el => (el.shadowRoot?.querySelector('.main') as HTMLElement)?.click());
+
+ // The component is stateless — this only holds if the page applied the
+ // change it reported.
+ await expect.poll(
+ () => target.evaluate(el => (el as HTMLElement & { shelf: number | null }).shelf),
+ { timeout: 5_000 },
+ ).toBe(1);
+
+ const server = await page.evaluate(async (olid) => {
+ const r = await fetch(`/reading-state.json?work_ids=${olid}`, { credentials: 'same-origin' });
+ return r.json();
+ }, workOlid);
+ expect(server.shelves[workOlid!]).toBe(1);
+
+ // Leave the patron's reading log as we found it.
+ await page.evaluate(async (key) => {
+ await fetch(`/works/${key}/bookshelves.json`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'bookshelf_id=-1',
+ });
+ }, workOlid);
+ });
+
+ test('opening the popover does not select the row for the librarian toolbar', async ({ page }) => {
+ await login(page);
+ await gotoResults(page);
+
+ // A click inside a shadow root retargets to the host, which is how the
+ // ILE selection guard stopped seeing the button it was meant to ignore.
+ await page.locator('ol-shelf-button[user-key]').first()
+ .evaluate(el => (el.shadowRoot?.querySelector('.more') as HTMLElement)?.click());
+
+ await expect.poll(
+ () => page.locator('.ile-selected').count(),
+ { timeout: 3_000 },
+ ).toBe(0);
+ });
+});
diff --git a/tests/unit/js/OlBookActions.test.js b/tests/unit/js/OlBookActions.test.js
index 46b768d3eb4..8ac1bd6207b 100644
--- a/tests/unit/js/OlBookActions.test.js
+++ b/tests/unit/js/OlBookActions.test.js
@@ -3,7 +3,7 @@
* updates, the state-change event, and the add-to-list pane (load, filter,
* toggle, create). Network is stubbed at `fetch`.
*/
-import { OlBookActions, resetListsCache, fmt } from '../../../openlibrary/components/lit/OlBookActions.js';
+import { OlBookActions, quickYears, resetListsCache, fmt } from '../../../openlibrary/components/lit/OlBookActions.js';
import { SHELF } from '../../../openlibrary/components/lit/utils/books-api.js';
const BOOK = { key: '/works/OL1W', title: 'Project Hail Mary', firstPublishYear: 2021, editionKey: 'OL9M' };
@@ -23,6 +23,7 @@ function stubFetch({ failWith } = {}) {
let body = {};
if (url.endsWith('/partials/MyBooksDropperLists.json')) body = { dropper: '', listData };
if (url.endsWith('/lists.json') && init?.method === 'POST') body = { key: '/people/tester/lists/OL3L', revision: 1 };
+ if (url.includes('/check-ins')) body = { status: 'ok', id: 42 };
return { ok: true, status: 200, json: async() => body };
});
}
@@ -245,6 +246,62 @@ describe('ol-book-actions lists pane', () => {
});
});
+describe('ol-book-actions list-created bridge', () => {
+ async function createList(el, name) {
+ q(el, '.group:last-child .row').click();
+ await tick(el);
+ q(el, '.lists-header ol-button').click();
+ await el.updateComplete;
+ const form = q(el, 'form.field');
+ form.querySelector('input').value = name;
+ form.dispatchEvent(new Event('submit', { cancelable: true }));
+ await tick(el);
+ }
+
+ test('creating a list announces it with ol-list-created', async() => {
+ stubFetch();
+ const el = await mount();
+ const seen = [];
+ document.addEventListener('ol-list-created', e => seen.push(e), { once: true });
+ await createList(el, 'Gothic autumn');
+ expect(seen).toHaveLength(1);
+ expect(seen[0].detail).toEqual({ key: '/people/tester/lists/OL3L', name: 'Gothic autumn', seedKey: '/works/OL1W' });
+ });
+
+ test('a sibling popover picks up the new list without refetching', async() => {
+ stubFetch();
+ const el = await mount();
+ const sibling = await mount({ book: { ...BOOK, key: '/works/OL2W' } });
+ await tick(sibling);
+ const fetches = () => calls.filter(c => c.url.endsWith('/partials/MyBooksDropperLists.json')).length;
+ const before = fetches();
+ await createList(el, 'Gothic autumn');
+ expect(Object.values(sibling._lists).map(l => l.listName)).toContain('Gothic autumn');
+ // Unchecked for the sibling: only the creator's seed is on the list.
+ sibling.shadowRoot.querySelector('.group:last-child .row').click();
+ await tick(sibling);
+ expect(qa(sibling, '.list-row')[0].querySelector('input').checked).toBe(false);
+ expect(fetches()).toBe(before);
+ });
+
+ test('a legacy creation merges in and drops the shared cache', async() => {
+ stubFetch();
+ const el = await mount();
+ await tick(el);
+ document.dispatchEvent(new CustomEvent('ol-list-created', {
+ detail: { key: '/people/tester/lists/OL9L', name: 'From the dropper', seedKey: '/works/OL5W' },
+ }));
+ expect(Object.values(el._lists).map(l => l.listName)).toContain('From the dropper');
+ // The shared promise predates the list, so a popover that has not
+ // loaded yet must fetch fresh rather than resolve from it.
+ const before = calls.filter(c => c.url.endsWith('/partials/MyBooksDropperLists.json')).length;
+ const late = await mount({ book: { ...BOOK, key: '/works/OL2W' } });
+ await tick(late);
+ const after = calls.filter(c => c.url.endsWith('/partials/MyBooksDropperLists.json')).length;
+ expect(after).toBe(before + 1);
+ });
+});
+
describe('ol-book-actions hide-rating', () => {
test('drops the stars but keeps shelves and lists', async() => {
stubFetch();
@@ -280,6 +337,23 @@ describe('ol-book-actions rejected writes', () => {
const checkInWrites = () => calls.filter(c => c.url.includes('/check-ins'));
const checkInPane = el => el.shadowRoot.querySelectorAll('.pane')[2];
const paneRows = el => [...checkInPane(el).querySelectorAll('.row')];
+const yearRows = el => [...checkInPane(el).querySelectorAll('.row.year')];
+const otherDateRow = el => checkInPane(el).querySelector('.row.date-toggle');
+
+describe('quickYears', () => {
+ test('one year once the new year has bedded in', () => {
+ expect(quickYears(new Date(2026, 7, 22))).toEqual([2026]);
+ });
+
+ test('the year just gone stays on offer for the first 30 days', () => {
+ expect(quickYears(new Date(2026, 0, 25))).toEqual([2026, 2025]);
+ expect(quickYears(new Date(2026, 0, 1))).toEqual([2026, 2025]);
+ });
+
+ test('and drops off after them', () => {
+ expect(quickYears(new Date(2026, 0, 31))).toEqual([2026]);
+ });
+});
describe('ol-book-actions check-in pane', () => {
test('marking a book read slides the date question in', async() => {
@@ -289,7 +363,7 @@ describe('ol-book-actions check-in pane', () => {
await tick(el);
expect(el._pane).toBe('checkIn');
expect(paneRows(el).map(r => r.textContent.trim())).toEqual([
- 'Today', `In ${new Date().getFullYear()}`, 'Other date',
+ 'Today', ...quickYears().map(y => `In ${y}`), 'Other date',
]);
});
@@ -301,13 +375,15 @@ describe('ol-book-actions check-in pane', () => {
expect(el._pane).toBe('main');
});
- test('a book already on the shelf does not ask again', async() => {
+ test('a book already on the shelf opens the pane to amend its date', async() => {
stubFetch();
- // Clicking the shelf it is on removes it; that is not a finish event.
+ // What the row's chevron promises — and the only way to change a date
+ // once given. Coming off the shelf is the main button's job.
const el = await mount({ shelf: SHELF.ALREADY_READ });
qa(el, '.group.shelves .row')[2].click();
await tick(el);
- expect(el._pane).toBe('main');
+ expect(el._pane).toBe('checkIn');
+ expect(calls.find(c => c.url === '/works/OL1W/bookshelves.json')).toBeUndefined();
});
test('rating a book does not, even though the server moves it to Already Read', async() => {
@@ -327,6 +403,35 @@ describe('ol-book-actions check-in pane', () => {
expect(el._pane).toBe('main');
});
+ test('the date already given rides on the Already Read row', async() => {
+ stubFetch();
+ const el = await mount({ shelf: SHELF.ALREADY_READ, readDate: '2026' });
+ const row = qa(el, '.group.shelves .row')[2];
+ expect(row.querySelector('.count').textContent).toBe('2026');
+ // A chevron, not a check: the row leads to the date pane.
+ expect(row.querySelector('.trail').getAttribute('name')).toBe('chevron-right');
+ });
+
+ test('a partial date shows only what is known', async() => {
+ stubFetch();
+ const el = await mount({ shelf: SHELF.ALREADY_READ, readDate: '2026-08' });
+ expect(qa(el, '.group.shelves .row')[2].querySelector('.count').textContent).toBe('Aug 2026');
+ });
+
+ test('amending a date edits the same check-in rather than adding one', async() => {
+ stubFetch();
+ const el = await mount({ shelf: SHELF.ALREADY_READ, readDate: '2025', eventId: 12 });
+ const events = [];
+ el.addEventListener('ol-book-check-in', e => events.push(e.detail));
+ qa(el, '.group.shelves .row')[2].click();
+ await tick(el);
+ yearRows(el)[0].click();
+ await tick(el);
+ const body = JSON.parse(checkInWrites()[0].init.body);
+ expect(body.event_id).toBe(12);
+ expect(events).toEqual([{ key: '/works/OL1W', date: String(new Date().getFullYear()), eventId: 42 }]);
+ });
+
test('Today posts a full date', async() => {
stubFetch();
const el = await mount();
@@ -341,6 +446,7 @@ describe('ol-book-actions check-in pane', () => {
month: now.getMonth() + 1,
day: now.getDate(),
edition_key: 'OL9M',
+ event_id: null,
});
expect(el._pane).toBe('main');
});
@@ -350,7 +456,7 @@ describe('ol-book-actions check-in pane', () => {
const el = await mount();
qa(el, '.group.shelves .row')[2].click();
await tick(el);
- paneRows(el)[1].click();
+ yearRows(el)[0].click();
await tick(el);
const body = JSON.parse(checkInWrites()[0].init.body);
expect(body.year).toBe(new Date().getFullYear());
@@ -363,11 +469,14 @@ describe('ol-book-actions check-in pane', () => {
const el = await mount();
qa(el, '.group.shelves .row')[2].click();
await tick(el);
- paneRows(el)[2].click();
+ otherDateRow(el).click();
await tick(el);
const selects = () => [...checkInPane(el).querySelectorAll('.select')];
expect(selects()).toHaveLength(3);
+ // Disclosed under the row, not in place of it: the one-tap answers
+ // stay on screen.
+ expect(paneRows(el)).toHaveLength(2 + yearRows(el).length);
expect(selects()[1].disabled).toBe(true);
expect(selects()[2].disabled).toBe(true);
@@ -383,6 +492,106 @@ describe('ol-book-actions check-in pane', () => {
expect(selects()[2].querySelectorAll('option')).toHaveLength(30);
});
+ // The pane is as often amending a date as asking for one, so it has to show
+ // what it already holds — otherwise three unmarked rows read as unanswered.
+ describe('a date already recorded', () => {
+ const pad = n => String(n).padStart(2, '0');
+ const now = new Date();
+ const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
+
+ const openPane = async readDate => {
+ stubFetch();
+ const el = await mount({ shelf: SHELF.ALREADY_READ, readDate });
+ qa(el, '.group.shelves .row')[2].click();
+ await tick(el);
+ return el;
+ };
+ const marked = el => paneRows(el)
+ .filter(r => r.getAttribute('aria-current') === 'true')
+ .map(r => r.querySelector('.label').textContent);
+
+ test('today\'s date marks Today', async() => {
+ expect(marked(await openPane(today))).toEqual(['Today']);
+ });
+
+ test('a bare current year marks that year', async() => {
+ expect(marked(await openPane(String(now.getFullYear())))).toEqual([`In ${now.getFullYear()}`]);
+ });
+
+ test('anything else marks Other date and shows the date on the row', async() => {
+ const el = await openPane('1998-03-14');
+ expect(marked(el)).toEqual(['Other date']);
+ expect(q(el, '.date-toggle .count').textContent).toBe('Mar 14, 1998');
+ });
+
+ test('no date marks nothing', async() => {
+ expect(marked(await openPane(null))).toEqual([]);
+ });
+
+ // A date the shortcuts cannot express is invisible behind a collapsed
+ // row, so the pane opens on it.
+ test('a date no shortcut can express opens the selects, seeded', async() => {
+ const el = await openPane('1998-03-14');
+ expect(el._pickingDate).toBe(true);
+ expect(qa(el, '.select').map(s => s.value)).toEqual(['1998', '3', '14']);
+ });
+
+ test('a partial date seeds only the parts it knows', async() => {
+ const el = await openPane('1998-03');
+ expect(qa(el, '.select').map(s => s.value)).toEqual(['1998', '3', '']);
+ });
+
+ test('a date a shortcut covers leaves them closed', async() => {
+ expect((await openPane(today))._pickingDate).toBe(false);
+ expect((await openPane(String(now.getFullYear())))._pickingDate).toBe(false);
+ });
+
+ // Lit commits a select's own bindings before its children, so seeding
+ // through the select's .value silently dropped; the selection rides on
+ // each option instead. Clearing has to survive the same round trip.
+ test('clearing the year blanks the selects it gated', async() => {
+ const el = await openPane('1998-03-14');
+ el._setDatePart('year', '');
+ await tick(el);
+ expect(qa(el, '.select').map(s => s.value)).toEqual(['', '', '']);
+ });
+ });
+
+ test('other date is a disclosure, so pressing it again closes the selects', async() => {
+ stubFetch();
+ const el = await mount();
+ qa(el, '.group.shelves .row')[2].click();
+ await tick(el);
+
+ const toggle = () => checkInPane(el).querySelector('.date-toggle');
+ // A down chevron, not a right one: nothing is being navigated to.
+ expect(toggle().querySelector('.trail').getAttribute('name')).toBe('chevron-down');
+ expect(toggle().getAttribute('aria-expanded')).toBe('false');
+
+ toggle().click();
+ await tick(el);
+ expect(toggle().getAttribute('aria-expanded')).toBe('true');
+
+ toggle().click();
+ await tick(el);
+ expect(toggle().getAttribute('aria-expanded')).toBe('false');
+ expect(checkInPane(el).querySelectorAll('.select')).toHaveLength(0);
+ // Closing the fields stays on the pane rather than backing out of it.
+ expect(el._pane).toBe('checkIn');
+ });
+
+ test('Today still answers while the selects are open', async() => {
+ stubFetch();
+ const el = await mount();
+ qa(el, '.group.shelves .row')[2].click();
+ await tick(el);
+ otherDateRow(el).click();
+ await tick(el);
+ paneRows(el)[0].click();
+ await tick(el);
+ expect(JSON.parse(checkInWrites()[0].init.body).day).toBe(new Date().getDate());
+ });
+
test('clearing the year clears what it gated', async() => {
stubFetch();
const el = await mount();
@@ -398,7 +607,7 @@ describe('ol-book-actions check-in pane', () => {
const el = await mount();
qa(el, '.group.shelves .row')[2].click();
await tick(el);
- paneRows(el)[2].click();
+ otherDateRow(el).click();
await tick(el);
el._setDatePart('year', '2024');
el._setDatePart('month', '6');
diff --git a/tests/unit/js/shelfButtons.test.js b/tests/unit/js/shelfButtons.test.js
new file mode 100644
index 00000000000..bf6a7863db3
--- /dev/null
+++ b/tests/unit/js/shelfButtons.test.js
@@ -0,0 +1,104 @@
+/**
+ * Unit tests for the page-level owner of `
` state.
+ *
+ * The buttons are stateless by contract, so without this module a shelf change
+ * is written to the server and then dropped on the floor: the label keeps
+ * showing whatever the server rendered. These tests pin that, plus the two
+ * things that only work because the state is applied centrally — duplicate
+ * cards for one work staying in step, and the finish date the popover shows.
+ */
+import { initShelfButtons } from '../../../openlibrary/plugins/openlibrary/js/my-books/shelf-buttons';
+
+/** A stand-in for the upgraded element: the module only sets properties. */
+function button(workKey) {
+ const el = document.createElement('ol-shelf-button');
+ el.setAttribute('work-key', workKey);
+ el.shelf = null;
+ el.rating = null;
+ document.body.appendChild(el);
+ return el;
+}
+
+function change(key, shelf, rating = null) {
+ document.dispatchEvent(new CustomEvent('ol-book-state-change', {
+ bubbles: true, composed: true, detail: { key, shelf, rating },
+ }));
+}
+
+function checkIn(key, date, eventId = 7) {
+ document.dispatchEvent(new CustomEvent('ol-book-check-in', {
+ bubbles: true, composed: true, detail: { key, date, eventId },
+ }));
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('applying reported state', () => {
+ test('a reported change lands on the button that reported it', () => {
+ const el = button('/works/OL1W');
+ initShelfButtons([el]);
+ change('/works/OL1W', 2, 4);
+ expect(el.shelf).toBe(2);
+ expect(el.rating).toBe(4);
+ });
+
+ test('every button for the same work moves together', () => {
+ const a = button('/works/OL1W');
+ const b = button('/works/OL1W');
+ initShelfButtons([a, b]);
+ change('/works/OL1W', 3);
+ expect([a.shelf, b.shelf]).toEqual([3, 3]);
+ });
+
+ test('other works are untouched', () => {
+ const a = button('/works/OL1W');
+ const b = button('/works/OL2W');
+ initShelfButtons([a, b]);
+ change('/works/OL1W', 1);
+ expect(b.shelf).toBeNull();
+ });
+
+ test('a rollback is applied the same way as the optimistic update', () => {
+ const el = button('/works/OL1W');
+ initShelfButtons([el]);
+ change('/works/OL1W', 1);
+ change('/works/OL1W', null);
+ expect(el.shelf).toBeNull();
+ });
+});
+
+describe('keeping the read date in step', () => {
+ test('a date saved in the popover lands on the button', () => {
+ const el = button('/works/OL1W');
+ initShelfButtons([el]);
+ checkIn('/works/OL1W', '2026-08-22', 12);
+ expect(el.readDate).toBe('2026-08-22');
+ expect(el.eventId).toBe(12);
+ });
+
+ test('a date for another work is left alone', () => {
+ const el = button('/works/OL1W');
+ initShelfButtons([el]);
+ checkIn('/works/OL2W', '2026');
+ expect(el.readDate).toBeUndefined();
+ });
+
+ test('coming off a shelf clears the date, which the server deletes too', () => {
+ const el = button('/works/OL1W');
+ initShelfButtons([el]);
+ checkIn('/works/OL1W', '2026');
+ change('/works/OL1W', null);
+ expect(el.readDate).toBeNull();
+ expect(el.eventId).toBeNull();
+ });
+
+ test('moving between shelves leaves the date alone', () => {
+ const el = button('/works/OL1W');
+ initShelfButtons([el]);
+ checkIn('/works/OL1W', '2026');
+ change('/works/OL1W', 2);
+ expect(el.readDate).toBe('2026');
+ });
+});