Repository: internetarchive/openlibrary

Issue:
Want to read button looks weird when logged out 
<!-- What problem are we solving? What does the experience look like today? What are the symptoms? -->

### Evidence / Screenshot (if possible)

When logged out:
<img width="666" alt="image" src="https://github.com/internetarchive/openlibrary/assets/921217/10c6dcf8-9728-4ae5-b596-aabeced2bfe9">

When logged in:
<img width="805" alt="image" src="https://github.com/internetarchive/openlibrary/assets/921217/9351b09a-e6f3-4dc0-b732-afdc217f8300">


### Relevant url?
https://openlibrary.org/search?q=fishing&mode=everything

### Steps to Reproduce
<!-- What steps caused you to find the bug? -->
1. Go to the website when logged out and search for any book

<!-- What actually happened after these steps? What did you expect to happen? -->
* Actual: Looks like a ghost
* Expected: Looks like a button

### Details

- **Logged in (Y/N)?** Y
- **Browser type/version?** FF/Chrome
- **Operating system?** Mac
- **Environment (prod/dev/local)?** prod
<!-- If not sure, put prod -->

### Proposal & Constraints
<!-- What is the proposed solution / implementation? Is there a precedent of this approach succeeding elsewhere? -->

### Related files
<!-- Files related to this issue; this is super useful for new contributors who might want to help! If you're not sure, leave this blank; a maintainer will add them. -->

### Stakeholders
<!-- @ tag stakeholders of this bug -->



Discussion:

Resolving patch:
diff --git a/openlibrary/plugins/openlibrary/lists.py b/openlibrary/plugins/openlibrary/lists.py
--- a/openlibrary/plugins/openlibrary/lists.py
+++ b/openlibrary/plugins/openlibrary/lists.py
@@ -129,8 +129,17 @@ def get_seed_info(doc):
 
 @public
 def get_list_data(list, seed, include_cover_url=True):
+    list_items = []
+    for s in list.get_seeds():
+        list_items.append(s.key)
+
     d = web.storage(
-        {"name": list.name or "", "key": list.key, "active": list.has_seed(seed)}
+        {
+            "name": list.name or "",
+            "key": list.key,
+            "active": list.has_seed(seed) if seed else False,
+            "list_items": list_items,
+        }
     )
     if include_cover_url:
         cover = list.get_cover() or list.get_default_cover()
@@ -150,7 +159,7 @@ def get_user_lists(seed_info):
     if not user:
         return []
     user_lists = user.get_lists(sort=True)
-    seed = seed_info['seed']
+    seed = seed_info['seed'] if seed_info else None
     return [get_list_data(user_list, seed) for user_list in user_lists]
 
 
@@ -160,9 +169,18 @@ class lists_partials(delegate.page):
 
     def GET(self):
         i = web.input(key=None)
-
+        use_legacy_droppers = "my_books_dropper" not in web.ctx.features
         user = get_current_user()
-        doc = self.get_doc(i.key)
+
+        if use_legacy_droppers:
+            partials = self.legacy_get_partials(i.key, user)
+        else:
+            partials = self.get_partials()
+
+        return delegate.RawText(json.dumps(partials))
+
+    def legacy_get_partials(self, key, user):
+        doc = self.get_doc(key)
         seed_info = get_seed_info(doc)
         user_lists = get_user_lists(seed_info)
 
@@ -171,12 +189,29 @@ def GET(self):
             'lists/active_lists', user_lists, user['key'], seed_info
         )
 
-        partials = {
+        return {
             'dropper': str(dropper),
             'active': str(active),
         }
 
-        return delegate.RawText(json.dumps(partials))
+    def get_partials(self):
+        user_lists = get_user_lists(None)
+
+        dropper = render_template(
+            'lists/dropper_lists', user_lists, legacy_rendering=False
+        )
+        list_data = {
+            list_data['key']: {
+                'members': list_data['list_items'],
+                'listName': list_data['name'],
+            }
+            for list_data in user_lists
+        }
+
+        return {
+            'dropper': str(dropper),
+            'listData': list_data,
+        }
 
     def get_doc(self, key):
         if key.startswith("/subjects/"):
diff --git a/openlibrary/plugins/upstream/mybooks.py b/openlibrary/plugins/upstream/mybooks.py
--- a/openlibrary/plugins/upstream/mybooks.py
+++ b/openlibrary/plugins/upstream/mybooks.py
@@ -172,6 +172,15 @@ def get_public_patron_account(username):
     return ReadingLog(user=user)
 
 
+@public
+def get_patrons_work_read_status(username, work_key):
+    if not username:
+        return None
+    work_id = extract_numeric_id_from_olid(work_key)
+    status_id = Bookshelves.get_users_read_status_of_work(username, work_id)
+    return status_id
+
+
 class MyBooksTemplate:
     # Reading log shelves
     READING_LOG_KEYS = {"currently-reading", "want-to-read", "already-read"}


Test patch:
diff --git a/tests/unit/js/droppers.test.js b/tests/unit/js/droppers.test.js
--- a/tests/unit/js/droppers.test.js
+++ b/tests/unit/js/droppers.test.js
@@ -31,7 +31,7 @@ describe('Generic Droppers', () => {
     test('Clicking dropclick element toggles the dropper', () => {
         // Setup
         document.body.innerHTML = closedDropperMarkup
-        const wrapper = document.querySelector('.dropper-wrapper')
+        const wrapper = document.querySelector('.generic-dropper-wrapper')
         const dropper = new Dropper(wrapper)
         dropper.initialize()
 
@@ -45,18 +45,18 @@ describe('Generic Droppers', () => {
         // Open dropper
         dropClick.click()
         expect(arrow.classList.contains('up')).toBe(true)
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(true)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(true)
 
         // Close dropper
         dropClick.click()
         expect(arrow.classList.contains('up')).toBe(false)
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(false)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(false)
     })
 
     test('Opened droppers close if they are not the target of a click', () => {
         // Setup
         document.body.innerHTML = openDropperMarkup.concat(openDropperMarkup, openDropperMarkup)
-        const wrappers = document.querySelectorAll('.dropper-wrapper')
+        const wrappers = document.querySelectorAll('.generic-dropper-wrapper')
         initGenericDroppers(wrappers)
 
 
@@ -64,8 +64,7 @@ describe('Generic Droppers', () => {
         expect(wrappers.length).toBe(3)
         for (const wrapper of wrappers) {
             const arrow = wrapper.querySelector('.arrow')
-
-            expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(true)
+            expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(true)
             expect(arrow.classList.contains('up')).toBe(true)
         }
 
@@ -74,35 +73,34 @@ describe('Generic Droppers', () => {
         dropdownContent.click()
 
         // First dropper should be open
-        expect(wrappers[0].classList.contains('dropper-wrapper--active')).toBe(true)
+        expect(wrappers[0].classList.contains('generic-dropper-wrapper--active')).toBe(true)
         expect(wrappers[0].querySelector('.arrow').classList.contains('up')).toBe(true)
 
         // ...while other droppers should be closed
         for (let i = 1; i < wrappers.length; ++i) {
             const arrow = wrappers[i].querySelector('.arrow')
-
-            expect(wrappers[i].classList.contains('dropper-wrapper--active')).toBe(false)
+            expect(wrappers[i].classList.contains('generic-dropper-wrapper--active')).toBe(false)
             expect(arrow.classList.contains('up')).toBe(false)
         }
     })
 
     test('Disabled droppers cannot be opened nor closed', () => {
         document.body.innerHTML = disabledDropperMarkup
-        const wrapper = document.querySelector('.dropper-wrapper')
+        const wrapper = document.querySelector('.generic-dropper-wrapper')
         const dropper = new Dropper(wrapper)
         dropper.initialize()
         const dropclick = wrapper.querySelector('.generic-dropper__dropclick')
         const arrow = wrapper.querySelector('.arrow')
 
         // Sanity checks
-        expect(wrapper.querySelector('div').classList.contains('generic-dropper--disabled')).toBe(true)
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(false)
+        expect(wrapper.classList.contains('generic-dropper--disabled')).toBe(true)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(false)
         expect(arrow.classList.contains('up')).toBe(false)
 
         // Click on the dropclick:
         dropclick.click()
 
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(false)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(false)
         expect(arrow.classList.contains('up')).toBe(false)
     })
 })
@@ -110,7 +108,7 @@ describe('Generic Droppers', () => {
 describe('Dropper.js class', () => {
     test('Dropper references set correctly on instantiation', () => {
         document.body.innerHTML = closedDropperMarkup
-        const wrapper = document.querySelector('.dropper-wrapper')
+        const wrapper = document.querySelector('.generic-dropper-wrapper')
         const dropper = new Dropper(wrapper)
 
         // Reference to component root stored
@@ -129,7 +127,7 @@ describe('Dropper.js class', () => {
 
     it('is not functional until initialize() is called', () => {
         document.body.innerHTML = closedDropperMarkup
-        const wrapper = document.querySelector('.dropper-wrapper')
+        const wrapper = document.querySelector('.generic-dropper-wrapper')
         const dropClick = wrapper.querySelector('.generic-dropper__dropclick')
         const arrow = wrapper.querySelector('.arrow')
 
@@ -137,19 +135,19 @@ describe('Dropper.js class', () => {
         const spy = jest.spyOn(dropper, 'toggleDropper')
 
         // Dropper should be closed initially:
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(false)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(false)
         expect(arrow.classList.contains('up')).toBe(false)
 
         // Clicking should not do anything yet:
         dropClick.click()
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(false)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(false)
         expect(arrow.classList.contains('up')).toBe(false)
         expect(spy).not.toHaveBeenCalled()
 
         // Test again after initialization:
         dropper.initialize()
         dropClick.click()
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(true)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(true)
         expect(arrow.classList.contains('up')).toBe(true)
         expect(spy).toHaveBeenCalled()
 
@@ -158,7 +156,7 @@ describe('Dropper.js class', () => {
 
     it('can be closed if not disabled', () => {
         document.body.innerHTML = openDropperMarkup
-        const wrapper = document.querySelector('.dropper-wrapper')
+        const wrapper = document.querySelector('.generic-dropper-wrapper')
         const arrow = wrapper.querySelector('.arrow')
 
         const dropper = new Dropper(wrapper)
@@ -167,19 +165,19 @@ describe('Dropper.js class', () => {
         // Check initial state:
         expect(dropper.isDropperDisabled).toBe(false)
         expect(dropper.isDropperOpen).toBe(true)
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(true)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(true)
         expect(arrow.classList.contains('up')).toBe(true)
 
         // Check again after closing:
         dropper.closeDropper()
         expect(dropper.isDropperOpen).toBe(false)
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(false)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(false)
         expect(arrow.classList.contains('up')).toBe(false)
     })
 
     it('can be toggled if not disabled', () => {
         document.body.innerHTML = closedDropperMarkup
-        const wrapper = document.querySelector('.dropper-wrapper')
+        const wrapper = document.querySelector('.generic-dropper-wrapper')
         const arrow = wrapper.querySelector('.arrow')
 
         const dropper = new Dropper(wrapper)
@@ -188,25 +186,25 @@ describe('Dropper.js class', () => {
         // Check initial state:
         expect(dropper.isDropperDisabled).toBe(false)
         expect(dropper.isDropperOpen).toBe(false)
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(false)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(false)
         expect(arrow.classList.contains('up')).toBe(false)
 
         // Check after toggling open:
         dropper.toggleDropper()
         expect(dropper.isDropperOpen).toBe(true)
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(true)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(true)
         expect(arrow.classList.contains('up')).toBe(true)
 
         // Check after toggling once more:
         dropper.toggleDropper()
         expect(dropper.isDropperOpen).toBe(false)
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(false)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(false)
         expect(arrow.classList.contains('up')).toBe(false)
     })
 
     it('cannot be opened while disabled', () => {
         document.body.innerHTML = disabledDropperMarkup
-        const wrapper = document.querySelector('.dropper-wrapper')
+        const wrapper = document.querySelector('.generic-dropper-wrapper')
         const dropper = new Dropper(wrapper)
         dropper.initialize()
         const arrow = wrapper.querySelector('.arrow')
@@ -214,11 +212,94 @@ describe('Dropper.js class', () => {
         // Check initial state:
         expect(dropper.isDropperDisabled).toBe(true)
         expect(arrow.classList.contains('up')).toBe(false)
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(false)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(false)
 
         // Check state after toggling:
         dropper.toggleDropper()
         expect(arrow.classList.contains('up')).toBe(false)
-        expect(wrapper.classList.contains('dropper-wrapper--active')).toBe(false)
+        expect(wrapper.classList.contains('generic-dropper-wrapper--active')).toBe(false)
+    })
+
+    describe('Dropper event methods', () => {
+        afterEach(() => {
+            jest.clearAllMocks()
+        })
+
+        it('calls `onDisabledClick()` when dropper is clicked while disabled', () => {
+            document.body.innerHTML = disabledDropperMarkup
+            const wrapper = document.querySelector('.generic-dropper-wrapper')
+            const dropper = new Dropper(wrapper)
+            dropper.initialize()
+
+            const onDisabledClickFn = jest.spyOn(dropper, 'onDisabledClick')
+
+            // Check initial state:
+            expect(dropper.isDropperDisabled).toBe(true)
+            expect(onDisabledClickFn).not.toHaveBeenCalled()
+
+            // Check state after toggling:
+            dropper.toggleDropper()
+            expect(dropper.isDropperDisabled).toBe(true)
+            expect(onDisabledClickFn).toHaveBeenCalledTimes(1)
+
+            // Check state after closing:
+            dropper.closeDropper()
+            expect(dropper.isDropperDisabled).toBe(true)
+            expect(onDisabledClickFn).toHaveBeenCalledTimes(2)
+        })
+
+        it('calls `onClose()` when active dropper is closed', () => {
+            document.body.innerHTML = openDropperMarkup
+            const wrapper = document.querySelector('.generic-dropper-wrapper')
+            const dropper = new Dropper(wrapper)
+            dropper.initialize()
+
+            const onCloseFn = jest.spyOn(dropper, 'onClose')
+
+            // Check initial state:
+            expect(dropper.isDropperOpen).toBe(true)
+            expect(onCloseFn).not.toHaveBeenCalled()
+
+            // Check state after closing:
+            dropper.closeDropper()
+            expect(dropper.isDropperOpen).toBe(false)
+            expect(onCloseFn).toHaveBeenCalledTimes(1)
+
+            // Check state after toggling open then closed:
+            dropper.toggleDropper()
+            expect(dropper.isDropperOpen).toBe(true)
+            expect(onCloseFn).toHaveBeenCalledTimes(1) // Should not be called when dropper is closed
+
+            dropper.toggleDropper()
+            expect(dropper.isDropperOpen).toBe(false)
+            expect(onCloseFn).toHaveBeenCalledTimes(2)
+        })
+
+        test('toggling dropper results in correct event method being called', () => {
+            document.body.innerHTML = closedDropperMarkup
+            const wrapper = document.querySelector('.generic-dropper-wrapper')
+            const dropper = new Dropper(wrapper)
+            dropper.initialize()
+
+            const onCloseFn = jest.spyOn(dropper, 'onClose')
+            const onOpenFn = jest.spyOn(dropper, 'onOpen')
+
+            // Check initial state:
+            expect(dropper.isDropperOpen).toBe(false)
+            expect(onCloseFn).not.toHaveBeenCalled()
+            expect(onOpenFn).not.toHaveBeenCalled()
+
+            // Check after toggling open:
+            dropper.toggleDropper()
+            expect(dropper.isDropperOpen).toBe(true)
+            expect(onCloseFn).toHaveBeenCalledTimes(0)
+            expect(onOpenFn).toHaveBeenCalledTimes(1)
+
+            // Check after toggling closed:
+            dropper.toggleDropper()
+            expect(dropper.isDropperOpen).toBe(false)
+            expect(onCloseFn).toHaveBeenCalledTimes(1)
+            expect(onOpenFn).toHaveBeenCalledTimes(1)
+        })
     })
 })
diff --git a/tests/unit/js/html-test-data.js b/tests/unit/js/html-test-data.js
--- a/tests/unit/js/html-test-data.js
+++ b/tests/unit/js/html-test-data.js
@@ -67,34 +67,6 @@ export const editionIdentifiersSample = `<fieldset id="identifiers" data-config=
 </table>
 </fieldset>`;
 
-/** Part/Simplification of the .widget-add element */
-export const listCreationForm = `
-    <form method="post" class="floatform" name="new-list" id="new-list">
-    <div class="formElement">
-        <div class="label">
-            <label for="list_label">Name:</label>
-        </div>
-        <div class="input">
-            <input type="text" name="list_label" id="list_label" class="text required" value="sample text" required="">
-        </div>
-    </div>
-    <div class="formElement">
-        <div class="label">
-            <label for="list_desc">Description:</label>
-        </div>
-        <div class="input">
-            <textarea name="list_desc" id="list_desc" rows="5" cols="30">Sample text</textarea>
-        </div>
-    </div>
-    <div class="formElement">
-        <div class="input">
-            <button id="create-list-button" type="submit" class="larger">Create new list</button>
-            &nbsp; &nbsp;
-            <a class="small dialog--close plain red" href="javascript:;">Cancel</a>
-        </div>
-    </div>
-    </form>`
-
 export const clamperSample = `
       <span class='clamp' data-before='▾  ' style='display: unset;'>
           <h6>Subjects</h6>
diff --git a/tests/unit/js/lists.test.js b/tests/unit/js/lists.test.js
--- a/tests/unit/js/lists.test.js
+++ b/tests/unit/js/lists.test.js
@@ -1,14 +1,23 @@
 import { clearCreateListForm, syncReadingLogDropdownRemoveWithPrimaryButton } from '../../../openlibrary/plugins/openlibrary/js/lists/index.js';
-import { listCreationForm, readingLogDropperForm } from './html-test-data';
+import { createActiveShowcaseItem, ShowcaseItem } from '../../../openlibrary/plugins/openlibrary/js/lists/ShowcaseItem.js';
+import { CreateListForm } from '../../../openlibrary/plugins/openlibrary/js/my-books/CreateListForm.js'
 
-describe('clearCreateListForm', () => {
-    document.body.innerHTML = listCreationForm
-    const listName = document.querySelector('#list_label')
-    const listDesc = document.querySelector('#list_desc')
+import { readingLogDropperForm } from './html-test-data';
+import { listCreationForm, filledListCreationForm, showcaseI18nInput, subjectShowcase, authorShowcase, workShowcase, editionShowcase, activeListShowcase, listsSectionShowcase } from './sample-html/lists-test-data'
 
+// Start : Legacy tests
+describe('clearCreateListForm', () => {
     test('Clears form when called', () => {
+        // Set up
+        document.body.innerHTML = filledListCreationForm
+        const listName = document.querySelector('#list_label')
+        const listDesc = document.querySelector('#list_desc')
+
+        // Form should be filled initially
         expect(listName.value.length).toBeGreaterThan(0)
         expect(listDesc.value.length).toBeGreaterThan(0)
+
+        // Test clearing the form
         clearCreateListForm()
         expect(listName.value.length).toBe(0)
         expect(listDesc.value.length).toBe(0)
@@ -77,3 +86,196 @@ describe('syncReadingLogDropdownRemoveWithPrimaryButton', () => {
         document.body.innerHTML = '';
     });
 });
+// End : Legacy tests
+
+describe('CreateListForm class tests', () => {
+    test('CreateListForm fields correctly set', () => {
+        document.body.innerHTML = listCreationForm
+        const formElem = document.querySelector('form')
+        const listForm = new CreateListForm(formElem)
+
+        const createListButton = document.querySelector('#create-list-button')
+        expect(listForm.createListButton === createListButton).toBe(true)
+
+        const listTitleInput = document.querySelector('#list_label')
+        expect(listForm.listTitleInput === listTitleInput).toBe(true)
+
+        const listDescriptionInput = document.querySelector('#list_desc')
+        expect(listForm.listDescriptionInput === listDescriptionInput).toBe(true)
+    })
+
+    test('`resetForm()` clears a filled form', () => {
+        document.body.innerHTML = listCreationForm
+        const formElem = document.querySelector('form')
+        const listForm = new CreateListForm(formElem)
+
+        // Initial checks
+        expect(listForm.listTitleInput.value).not.toBeTruthy()
+        expect(listForm.listDescriptionInput.value).not.toBeTruthy()
+
+        // After setting input values
+        listForm.listTitleInput.value = 'New List'
+        listForm.listDescriptionInput.value = 'My new list.'
+        expect(listForm.listTitleInput.value).toBeTruthy()
+        expect(listForm.listDescriptionInput.value).toBeTruthy()
+
+        // After clearing the form:
+        listForm.resetForm()
+        expect(listForm.listTitleInput.value).not.toBeTruthy()
+        expect(listForm.listDescriptionInput.value).not.toBeTruthy()
+    })
+
+    it('should have empty inputs after instantiation', () => {
+        document.body.innerHTML = filledListCreationForm
+        const formElem = document.querySelector('form')
+        const titleInput = formElem.querySelector('#list_label')
+        const descriptionInput = formElem.querySelector('#list_desc')
+
+        // Form is initially filled
+        expect(titleInput.value).toBeTruthy()
+        expect(descriptionInput.value).toBeTruthy()
+
+        // Creating new CreateListForm should clear the form
+        // eslint-disable-next-line no-unused-vars
+        const listForm = new CreateListForm(formElem)
+        expect(titleInput.value).not.toBeTruthy()
+        expect(descriptionInput.value).not.toBeTruthy()
+    })
+})
+
+describe('createActiveShowcaseItem() tests', () => {
+    test('createActiveShowcaseItem() results are as expected', () => {
+        document.body.innerHTML = showcaseI18nInput
+        const listKey = '/people/openlibrary/lists/OL1L'
+        const seedKey = '/books/OL3421846M'
+        const listTitle = 'My First List'
+        const coverUrl = '/images/icons/avatar_book-sm.png'
+
+        const li = createActiveShowcaseItem(listKey, seedKey, listTitle, coverUrl)
+        const anchors = li.querySelectorAll('a')
+        const [imageLink, titleLink, removeLink] = anchors
+        const inputs = li.querySelectorAll('input')
+        const [titleInput, seedKeyInput, seedTypeInput] = inputs
+
+
+        // Must have `actionable-item` class
+        expect(li.classList.contains('actionable-item')).toBe(true)
+
+        // List key has been set
+        expect(removeLink.dataset.listKey === listKey).toBe(true)
+        expect(imageLink.href.endsWith(listKey)).toBe(true)
+        expect(titleLink.href.endsWith(listKey)).toBe(true)
+        expect(removeLink.href.endsWith(listKey)).toBe(true)
+
+        // Seed key has been set
+        expect(seedKeyInput.value === seedKey).toBe(true)
+        expect(seedTypeInput.value === 'edition').toBe(true)
+
+        // List title has been set
+        expect(titleLink.dataset.listTitle === listTitle).toBe(true)
+        expect(titleLink.textContent === listTitle).toBe(true)
+        expect(titleInput.value === listTitle).toBe(true)
+
+        // Cover URL has been set
+        expect(imageLink.children[0].src.endsWith(coverUrl)).toBe(true)
+    })
+
+    test('createActiveShowcaseItem() sets the correct seed type', () => {
+        const listKey = '/people/openlibrary/lists/OL1L'
+        const listTitle = 'My First List'
+        const coverUrl = '/images/icons/avatar_book-sm.png'
+
+        const editionKey = '/books/OL3421846M'
+        const workKey = '/works/OL54120W'
+        const authorKey = '/authors/OL18319A'
+        const subjectKey = 'quotations'
+        const bogusKey = '/bogus/OL38475839B'
+
+        const editionItem = createActiveShowcaseItem(listKey, editionKey, listTitle, coverUrl)
+        expect(editionItem.querySelector('input[name=seed-type]').value).toBe('edition')
+
+        const workItem = createActiveShowcaseItem(listKey, workKey, listTitle, coverUrl)
+        expect(workItem.querySelector('input[name=seed-type]').value).toBe('work')
+
+        const authorItem = createActiveShowcaseItem(listKey, authorKey, listTitle, coverUrl)
+        expect(authorItem.querySelector('input[name=seed-type]').value).toBe('author')
+
+        const subjectItem = createActiveShowcaseItem(listKey, subjectKey, listTitle, coverUrl)
+        expect(subjectItem.querySelector('input[name=seed-type]').value).toBe('subject')
+
+        const bogusItem = createActiveShowcaseItem(listKey, bogusKey, listTitle, coverUrl)
+        expect(bogusItem.querySelector('input[name=seed-type]').value).toBe('undefined')
+    })
+
+    it('sets the correct default value for `coverUrl`', () => {
+        document.body.innerHTML = showcaseI18nInput
+        const listKey = '/people/openlibrary/lists/OL1L'
+        const seedKey = '/books/OL3421846M'
+        const listTitle = 'My First List'
+
+        const li = createActiveShowcaseItem(listKey, seedKey, listTitle)
+        const coverImage = li.querySelector('img')
+
+        const expectedCoverUrl = '/images/icons/avatar_book-sm.png'
+        expect(coverImage.src.endsWith(expectedCoverUrl)).toBe(true)
+    })
+})
+
+describe('ShowcaseItem class tests', () => {
+    test('ShowcaseItem fields correctly set', () => {
+        document.body.innerHTML = activeListShowcase
+        const showcaseElem = document.querySelector('.actionable-item')
+        const showcase = new ShowcaseItem(showcaseElem)
+        const removeAffordance = showcaseElem.querySelector('.remove-from-list')
+
+        expect(showcase.showcaseElem === showcaseElem).toBe(true)
+        expect(showcase.isActiveShowcase).toBe(true)
+        expect(showcase.removeFromListAffordance === removeAffordance).toBe(true)
+        expect(showcase.listKey === '/people/openlibrary/lists/OL1L').toBe(true)
+        expect(showcase.seedKey === '/works/OL54120W').toBe(true)
+        expect(showcase.type).toBe('work')
+        expect(showcase.seed).toMatchObject({key: '/works/OL54120W'})
+    })
+
+    it('correctly infers if it is an active showcase', () => {
+        document.body.innerHTML = activeListShowcase + listsSectionShowcase
+        const [activeShowcaseElem, otherShowcaseElem] = document.querySelectorAll('.actionable-item')
+        const activeShowcase = new ShowcaseItem(activeShowcaseElem)
+        const otherShowcase = new ShowcaseItem(otherShowcaseElem)
+
+        expect(activeShowcase.isActiveShowcase).toBe(true)
+        expect(otherShowcase.isActiveShowcase).toBe(false)
+    })
+
+    describe('Seed type inference', () => {
+        const cases = [
+            {markup: subjectShowcase, expectedType: 'subject', expectedIsWorkValue: false, expectedIsSubjectValue: true},
+            {markup: authorShowcase, expectedType: 'author', expectedIsWorkValue: false, expectedIsSubjectValue: false},
+            {markup: workShowcase, expectedType: 'work', expectedIsWorkValue: true, expectedIsSubjectValue: false},
+            {markup: editionShowcase, expectedType: 'edition', expectedIsWorkValue: false, expectedIsSubjectValue: false}
+        ]
+
+        test.each(cases)('Type is $expectedType', ({markup, expectedType}) => {
+            document.body.innerHTML = markup
+            const showcaseElem = document.querySelector('.actionable-item')
+            const showcase = new ShowcaseItem(showcaseElem)
+            expect(showcase.type).toBe(expectedType)
+        })
+
+        test.each(cases)('`isWork` value expected to be $expectedIsWorkValue', ({markup, expectedIsWorkValue}) => {
+            document.body.innerHTML = markup
+            const showcaseElem = document.querySelector('.actionable-item')
+            const showcase = new ShowcaseItem(showcaseElem)
+            expect(showcase.isWork).toBe(expectedIsWorkValue)
+        })
+
+        test.each(cases)('`isSubject` value expected to be $expectedIsSubjectValue', ({markup, expectedIsSubjectValue}) => {
+            document.body.innerHTML = markup
+            const showcaseElem = document.querySelector('.actionable-item')
+            const showcase = new ShowcaseItem(showcaseElem)
+            expect(showcase.isSubject).toBe(expectedIsSubjectValue)
+        })
+    })
+
+    // XXX : test : removeSelf() fails safely when myBooksStore has not been created?
+})
diff --git a/tests/unit/js/my-books.test.js b/tests/unit/js/my-books.test.js
new file mode 100644
--- /dev/null
+++ b/tests/unit/js/my-books.test.js
@@ -0,0 +1,43 @@
+import { listCreationForm } from './sample-html/lists-test-data'
+import { CreateListForm } from '../../../openlibrary/plugins/openlibrary/js/my-books/CreateListForm'
+
+describe('CreateListForm.js class', () => {
+    let form
+    let formElem
+
+    beforeEach(() => {
+        document.body.innerHTML = listCreationForm
+        formElem = document.querySelector('form')
+        form = new CreateListForm(formElem)
+    })
+
+    test('References are set correctly', () => {
+        const createListButton = formElem.querySelector('#create-list-button')
+        const nameInput = formElem.querySelector('#list_label')
+        const descriptionInput = formElem.querySelector('#list_desc')
+
+        expect(createListButton === form.createListButton).toBe(true)
+        expect(nameInput === form.listTitleInput).toBe(true)
+        expect(descriptionInput === form.listDescriptionInput).toBe(true)
+    })
+
+    it('it clears the form after a resetForm() call', () => {
+        const nameInput = document.querySelector('#list_label')
+        const descriptionInput = document.querySelector('#list_desc')
+
+        // Form should be empty initially
+        expect(nameInput.value.length).toBe(0)
+        expect(descriptionInput.value.length).toBe(0)
+
+        // Add values to each input
+        nameInput.value = 'My New List'
+        descriptionInput.value = 'The best list ever'
+        expect(nameInput.value.length).toBeGreaterThan(0)
+        expect(descriptionInput.value.length).toBeGreaterThan(0)
+
+        // After clearing the form
+        form.resetForm()
+        expect(nameInput.value.length).toBe(0)
+        expect(descriptionInput.value.length).toBe(0)
+    })
+})
diff --git a/tests/unit/js/sample-html/dropper-test-data.js b/tests/unit/js/sample-html/dropper-test-data.js
--- a/tests/unit/js/sample-html/dropper-test-data.js
+++ b/tests/unit/js/sample-html/dropper-test-data.js
@@ -14,27 +14,26 @@ export const closedDropperMarkup = generateDropperMarkup(false)
 export const disabledDropperMarkup = generateDropperMarkup(false, true)
 
 function generateDropperMarkup(isDropperOpen, isDropperDisabled = false) {
-    let wrapperClasses = 'dropper-wrapper'
+    let wrapperClasses = 'generic-dropper-wrapper'
     let arrowClasses = 'arrow'
 
     if (isDropperOpen) {
-        wrapperClasses += ' dropper-wrapper--active'
+        wrapperClasses += ' generic-dropper-wrapper--active'
         arrowClasses += ' up'
     }
 
-    let dropperClasses = 'generic-dropper'
     if (isDropperDisabled) {
-        dropperClasses += ' generic-dropper--disabled'
+        wrapperClasses += ' generic-dropper--disabled'
     }
 
     return `
       <div class="${wrapperClasses}">
-        <div class="${dropperClasses}">
+        <div class="generic-dropper">
           <div class="generic-dropper__actions">
             <div class="generic-dropper__primary">
               <button>Primary Action</button>
             </div>
-            <a class="generic-dropper__dropclick generic-dropper__dropclick--unactivated" href="javascript:;">
+            <a class="generic-dropper__dropclick" href="javascript:;">
               <div class="${arrowClasses}"></div>
             </a>
           </div>
diff --git a/tests/unit/js/sample-html/lists-test-data.js b/tests/unit/js/sample-html/lists-test-data.js
new file mode 100644
--- /dev/null
+++ b/tests/unit/js/sample-html/lists-test-data.js
@@ -0,0 +1,131 @@
+function createListFormMarkup(isFilled) {
+    const listName = isFilled ? 'My New List' : ''
+    const listDescription = isFilled ? 'A list for all of my books' : ''
+
+    return `
+      <form method="post" class="floatform" name="new-list" id="new-list">
+      <div class="formElement">
+          <div class="label">
+              <label for="list_label">Name:</label>
+          </div>
+          <div class="input">
+              <input type="text" name="list_label" id="list_label" class="text required" value="${listName}" required="">
+          </div>
+      </div>
+      <div class="formElement">
+          <div class="label">
+              <label for="list_desc">Description:</label>
+          </div>
+          <div class="input">
+              <textarea name="list_desc" id="list_desc" rows="5" cols="30">${listDescription}</textarea>
+          </div>
+      </div>
+      <div class="formElement">
+          <div class="input">
+              <button id="create-list-button" type="submit" class="larger">Create new list</button>
+              &nbsp; &nbsp;
+              <a class="small dialog--close plain red" href="javascript:;">Cancel</a>
+          </div>
+      </div>
+      </form>
+    `
+}
+
+export const listCreationForm = createListFormMarkup(false)
+export const filledListCreationForm = createListFormMarkup(true)
+
+export const showcaseI18nInput = '<input type="hidden" name="list-i18n-strings" value="{&quot;cover_of&quot;: &quot;Cover of: &quot;, &quot;see_this_list&quot;: &quot;See this list&quot;, &quot;remove_from_list&quot;: &quot;Remove from your list?&quot;, &quot;from&quot;: &quot;from&quot;, &quot;you&quot;: &quot;You&quot;}"></input>'
+
+const DEFAULT_COVER_URL = '/images/icons/avatar_book-sm.png'
+
+/**
+ * @typedef {Object} ShowcaseDetails
+ * @property {string} listKey
+ * @property {string} seedKey
+ * @property {string} listTitle
+ * @property {string} listOwner
+ * @property {string} seedType
+ */
+/**
+ *
+ * @param {boolean} isActiveShowcase
+ * @param {Array<ShowcaseDetails>} showcaseData
+ */
+function createShowcaseMarkup(isActiveShowcase, showcaseData) {
+    const listId = isActiveShowcase ? 'already-lists' : 'list-lists'
+    const listClasses = 'listLists'.concat(isActiveShowcase ? ' already-lists' : '')
+
+    let showcaseMarkup = ''
+
+    for (const data of showcaseData) {
+        showcaseMarkup += `<li class="actionable-item">
+            <span class="image">
+              <a href="${data.listKey}"><img src="${DEFAULT_COVER_URL}" alt="Cover of: ${data.listTitle}" title="Cover of: ${data.listTitle}"></a>
+            </span>
+            <span class="data">
+                <span class="label">
+                    <a href="${data.listKey}" data-list-title="${data.listTitle}" title="See this list">${data.listTitle}</a>
+                    <input type="hidden" name="seed-title" value="${data.listTitle}">
+                    <input type="hidden" name="seed-key" value="${data.seedKey}">
+                    <input type="hidden" name="seed-type" value="${data.seedType}">
+
+                    <a href="${data.listKey}" class="remove-from-list red smaller arial plain" data-list-key="${data.listKey}" title="Remove from your list?">[X]</a>
+                </span>
+                <span class="owner">from <a href="${data.listOwner}">You</a></span>
+            </span>
+        </li>
+        `
+    }
+
+    return `<ul id="${listId}" class="${listClasses}">
+            ${showcaseMarkup}
+        </ul>
+        `
+}
+
+export const showcaseDetailsData = [
+    {
+        listKey: '/people/openlibrary/lists/OL1L',
+        seedKey: '/works/OL54120W',
+        listTitle: 'My First List',
+        listOwner: '/people/openlibrary',
+        seedType: 'work'
+    },
+    {
+        listKey: '/people/openlibrary/lists/OL1L',
+        seedKey: '/books/OL3421846M',
+        listTitle: 'My First List',
+        listOwner: '/people/openlibrary',
+        seedType: 'edition'
+    },
+    {
+        listKey: '/people/openlibrary/lists/OL2L',
+        seedKey: '/works/OL54120W',
+        listTitle: 'Another List',
+        listOwner: '/people/openlibrary',
+        seedType: 'work'
+    },
+    {
+        listKey: '/people/openlibrary/lists/OL1L',
+        seedKey: '/authors/OL18319A',
+        listTitle: 'My First List',
+        listOwner: '/people/openlibrary',
+        seedType: 'author'
+    },
+    {
+        listKey: '/people/openlibrary/lists/OL1L',
+        seedKey: 'quotations',
+        listTitle: 'My First List',
+        listOwner: '/people/openlibrary',
+        seedType: 'subject'
+    },
+]
+
+export const multipleShowcasesOnPage = createShowcaseMarkup(true, [showcaseDetailsData[0], showcaseDetailsData[2]]) + createShowcaseMarkup(false, [showcaseDetailsData[0], showcaseDetailsData[1]])
+export const activeListShowcase = createShowcaseMarkup(true, [showcaseDetailsData[0]])
+export const listsSectionShowcase = createShowcaseMarkup(false, [showcaseDetailsData[0]])
+export const subjectShowcase = createShowcaseMarkup(false, [showcaseDetailsData[4]])
+export const authorShowcase = createShowcaseMarkup(false, [showcaseDetailsData[3]])
+export const workShowcase = createShowcaseMarkup(false, [showcaseDetailsData[0]])
+export const editionShowcase = createShowcaseMarkup(false, [showcaseDetailsData[1]])
+
diff --git a/tests/unit/js/sample-html/utils-test-data.js b/tests/unit/js/sample-html/utils-test-data.js
new file mode 100644
--- /dev/null
+++ b/tests/unit/js/sample-html/utils-test-data.js
@@ -0,0 +1,17 @@
+// removeChildren() test data:
+
+// Single element, no children
+export const childlessElem = '<div class="remove-tests"></div>'
+
+// Single element, multiple children
+export const multiChildElem = `<div class="remove-tests">
+  <div>Child one</div>
+  <div>Child two</div>
+</div>`
+
+// Single element, child with children
+export const elemWithDescendants = `<div class="remove-tests">
+  <div>
+    <div>Ancestor</div>
+  </div>
+</div>`
diff --git a/tests/unit/js/utils.test.js b/tests/unit/js/utils.test.js
new file mode 100644
--- /dev/null
+++ b/tests/unit/js/utils.test.js
@@ -0,0 +1,65 @@
+import { childlessElem, multiChildElem, elemWithDescendants } from './sample-html/utils-test-data';
+import { removeChildren } from '../../../openlibrary/plugins/openlibrary/js/utils';
+
+describe('`removeChildren()` tests', () => {
+    it('changes nothing if element has no children', () => {
+        document.body.innerHTML = childlessElem
+        const elem = document.querySelector('.remove-tests')
+        const clonedElem = elem.cloneNode(true)
+
+        // Initial checks
+        expect(elem.childElementCount).toBe(0)
+        expect(elem.isEqualNode(clonedElem)).toBe(true)
+
+        // Element should be unchanged after function call
+        removeChildren(elem)
+        expect(elem.childElementCount).toBe(0)
+        expect(elem.isEqualNode(clonedElem)).toBe(true)
+    })
+
+    it('removes all of an element\'s children', () => {
+        document.body.innerHTML = multiChildElem
+        const elem = document.querySelector('.remove-tests')
+        const clonedElem = elem.cloneNode(true)
+
+        // Initial checks
+        expect(elem.childElementCount).toBe(2)
+        expect(elem.isEqualNode(clonedElem)).toBe(true)
+
+        // After removing children
+        removeChildren(elem)
+        expect(elem.childElementCount).toBe(0)
+        expect(elem.isEqualNode(clonedElem)).toBe(false)
+    })
+
+    it('removes children if they have children of their own', () => {
+        document.body.innerHTML = elemWithDescendants
+        const elem = document.querySelector('.remove-tests')
+        const clonedElem = elem.cloneNode(true)
+
+        // Inital checks
+        expect(elem.childElementCount).toBe(1)
+        expect(elem.children[0].childElementCount).toBe(1)
+        expect(elem.isEqualNode(clonedElem)).toBe(true)
+
+        // After removing children
+        removeChildren(elem)
+        expect(elem.childElementCount).toBe(0)
+        expect(elem.isEqualNode(clonedElem)).toBe(false)
+    })
+
+    it('handles multiple parameters correctly', () => {
+        document.body.innerHTML = elemWithDescendants + multiChildElem
+        const elems = document.querySelectorAll('.remove-tests')
+
+        // Initial checks:
+        expect(elems.length).toBe(2)
+        expect(elems[0].childElementCount).toBe(1)
+        expect(elems[1].childElementCount).toBe(2)
+
+        // After removing children:
+        removeChildren(...elems)
+        expect(elems[0].childElementCount).toBe(0)
+        expect(elems[1].childElementCount).toBe(0)
+    })
+})