From 03f7375c6b2065a86f101d38e60524e8c27ff0e8 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 17 Aug 2026 14:36:03 -0500 Subject: [PATCH] Add sidebar search bar --- package.json | 2 +- src/css/style.css | 88 ++++++ src/js/components/sidebar-search.js | 223 ++++++++++++++ src/js/dataLayer/dataStore.js | 2 + src/js/dataLayer/derived.js | 5 + src/js/dataLayer/requests.js | 56 ++-- src/js/mainLayout.js | 7 + src/js/views/search.view.js | 2 +- .../e2e/specs/concerns/sidebarSearch.test.js | 89 ++++++ .../specs/components/sidebar-search.test.js | 272 ++++++++++++++++++ tests/unit/specs/dataLayer/requests.test.js | 76 +++++ tests/unit/specs/mainLayout.test.js | 21 ++ 12 files changed, 815 insertions(+), 28 deletions(-) create mode 100644 src/js/components/sidebar-search.js create mode 100644 tests/e2e/specs/concerns/sidebarSearch.test.js create mode 100644 tests/unit/specs/components/sidebar-search.test.js diff --git a/package.json b/package.json index aa95fe20..2ef9d959 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.19.4", + "version": "0.19.5", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 6c13ca5e..a41b2c13 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -193,6 +193,7 @@ html { --floating-back-button-z-index: 3; --reply-prompt-z-index: 4; --message-input-z-index: 4; + --sidebar-search-z-index: 4; --reaction-palette-z-index: 5; --toast-z-index: 5; --fab-z-index: 6; @@ -391,6 +392,7 @@ video { position: sticky; top: 0; align-self: flex-start; + min-height: 100dvh; max-height: 100dvh; overflow-y: auto; } @@ -5990,6 +5992,92 @@ button.profile-list-item-button.is-disabled { border-bottom: none; } +sidebar-search { + display: none; +} + +@media (min-width: 1100px) { + sidebar-search { + display: block; + } +} + +.sidebar-search { + position: relative; + z-index: var(--sidebar-search-z-index); + margin: 16px 16px 0 16px; +} + +.sidebar-search-input-container { + position: relative; + display: flex; +} + +.sidebar-search-input { + width: 100%; + box-sizing: border-box; + padding: 10px 36px 10px 42px; + border: var(--hair) solid var(--post-border-color); + border-radius: var(--text-input-border-radius); + background: var(--text-input-background-color); + color: var(--text-color); + font-size: 16px; + outline: none; + transition: border-color 0.2s; +} + +.sidebar-search-input::-webkit-search-cancel-button { + display: none; +} + +@media (hover: hover) { + .sidebar-search-input:hover { + background: var(--post-hover-color); + } +} + +.sidebar-search-input-container .search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + color: var(--text-color-muted); + pointer-events: none; +} + +.sidebar-search-clear-button { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + background: none; +} + +.sidebar-search-typeahead { + position: absolute; + z-index: 2; + left: 0; + right: 0; + top: calc(100% + 4px); + overflow: hidden; + border: var(--hair) solid var(--post-border-color); + border-radius: 12px; + background: var(--background-color); + box-shadow: var(--shadow-color-light); +} + +.sidebar-search-typeahead-row { + padding-top: 10px; + padding-left: 8px; + padding-right: 8px; + padding-bottom: 8px; + border-bottom: none; +} + .feeds-list { display: flex; flex-direction: column; diff --git a/src/js/components/sidebar-search.js b/src/js/components/sidebar-search.js new file mode 100644 index 00000000..39a31d2e --- /dev/null +++ b/src/js/components/sidebar-search.js @@ -0,0 +1,223 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { Signal, effect } from "/js/signals.js"; +import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js"; +import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; +import { avatarTemplate } from "/js/templates/avatar.template.js"; +import { getDisplayName } from "/js/dataHelpers.js"; +import { linkToProfile } from "/js/navigation.js"; +import "/js/components/container-link.js"; + +const TYPEAHEAD_LIMIT = 8; + +function typeaheadTemplate({ query, profiles, onCommit, onSelectProfile }) { + return html``; +} + +function sidebarSearchTemplate({ + inputValue, + showTypeahead, + profiles, + onInput, + onKeydown, + onFocus, + onBlur, + onClear, + onCommit, + onSelectProfile, +}) { + return html``; +} + +class SidebarSearch extends Component { + connectedCallback() { + if (this.initialized) return; + this.initialized = true; + if (!this.dataLayer) { + throw new Error("sidebar-search requires a dataLayer property"); + } + this.$inputValue = new Signal.State(""); + this.$showTypeahead = new Signal.State(false); + this._disposers = [effect(() => this.render())]; + } + + disconnectedCallback() { + if (!this.initialized) return; + this._disposers?.forEach((dispose) => dispose()); + this._disposers = null; + this.initialized = false; + } + + render() { + const inputValue = this.$inputValue.get(); + const showTypeahead = this.$showTypeahead.get(); + const profiles = showTypeahead + ? this.dataLayer.derived.$sidebarSearchTypeaheadResults.get() + : null; + render( + sidebarSearchTemplate({ + inputValue, + showTypeahead, + profiles, + onInput: (value) => this.handleInput(value), + onKeydown: (event) => this.handleKeydown(event), + onFocus: () => this.handleFocus(), + onBlur: () => this.$showTypeahead.set(false), + onClear: () => this.handleClear(), + onCommit: () => this.commitSearch(), + onSelectProfile: (did) => this.handleSelectProfile(did), + }), + this, + ); + } + + loadTypeahead(query) { + this.dataLayer.requests + .loadSidebarSearchTypeahead(query, { limit: TYPEAHEAD_LIMIT }) + .catch((error) => console.warn("Typeahead search failed", error)); + } + + handleInput(value) { + this.$inputValue.set(value); + const trimmed = value.trim(); + if (!trimmed) { + this.$showTypeahead.set(false); + this.loadTypeahead(""); + return; + } + this.$showTypeahead.set(true); + this.loadTypeahead(trimmed); + } + + handleFocus() { + if (this.$inputValue.get().trim()) { + this.$showTypeahead.set(true); + } + } + + handleKeydown(event) { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + this.commitSearch(); + } else if (event.key === "Escape") { + event.preventDefault(); + this.handleClear(); + } + } + + reset() { + this.$inputValue.set(""); + this.$showTypeahead.set(false); + this.loadTypeahead(""); + } + + handleClear() { + this.reset(); + this.querySelector(".sidebar-search-input")?.focus(); + } + + handleSelectProfile(did) { + if (this.isAuthenticated) { + this.dataLayer.mutations.addRecentSearchProfile(did).catch(console.warn); + } + this.reset(); + this.querySelector(".sidebar-search-input")?.blur(); + } + + commitSearch() { + const query = this.$inputValue.get().trim(); + if (!query) return; + if (this.isAuthenticated) { + this.dataLayer.mutations.addRecentSearch(query).catch(console.warn); + } + this.reset(); + this.querySelector(".sidebar-search-input")?.blur(); + const params = new URLSearchParams(); + params.set("q", query); + window.router.go(`/search?${params.toString()}`); + } +} + +SidebarSearch.register(); diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index 594ea7b8..92c2faf8 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -10,6 +10,7 @@ export class DataStore extends ReactiveStore { this.$profileSearchResults = new Signal.State(null); this.$chatRecipientSearchResults = new Signal.State(null); this.$searchTypeaheadResults = new Signal.State(null); + this.$sidebarSearchTypeaheadResults = new Signal.State(null); this.$feedSearchResults = new Signal.State(null); this.$notifications = new Signal.State(null); this.$mentionNotifications = new Signal.State(null); @@ -24,6 +25,7 @@ export class DataStore extends ReactiveStore { this.$latestProfileSearchRequestTime = new Signal.State(null); this.$latestChatRecipientSearchRequestTime = new Signal.State(null); this.$latestSearchTypeaheadRequestTime = new Signal.State(null); + this.$latestSidebarSearchTypeaheadRequestTime = new Signal.State(null); this.$latestFeedSearchRequestTime = new Signal.State(null); this.$trends = new Signal.State(null); this.$postSearchResultsTop = new Signal.State(null); diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index 6c8960fb..ebeaa5cf 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -312,6 +312,11 @@ export class Derived extends ReactiveStore { if (!data) return null; return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did)); }); + this.$sidebarSearchTypeaheadResults = new Signal.Computed(() => { + const data = this.dataStore.$sidebarSearchTypeaheadResults.get(); + if (!data) return null; + return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did)); + }); this.$recentSearchTerms = new Signal.Computed(() => { const preferences = this.$preferences.get(); if (!preferences) return []; diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index c0485678..e3ef5c26 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -203,6 +203,10 @@ export class Requests { ); this.enableStatus(this.loadChatRecipientSearch, "loadChatRecipientSearch"); this.enableStatus(this.loadSearchTypeahead, "loadSearchTypeahead"); + this.enableStatus( + this.loadSidebarSearchTypeahead, + "loadSidebarSearchTypeahead", + ); this.enableStatus( this.loadPostSearchTop, (query) => "loadPostSearchTop-" + query, @@ -648,50 +652,50 @@ export class Requests { } } - async loadChatRecipientSearch(query, { limit = 12 } = {}) { + async _loadProfileTypeahead(query, { limit, $results, $latestRequestTime }) { if (!query) { // Invalidate in-flight searches so they can't repopulate cleared results - this.dataStore.$latestChatRecipientSearchRequestTime.set(null); - this.dataStore.$chatRecipientSearchResults.set(null); + $latestRequestTime.set(null); + $results.set(null); return; } const labelers = this.requireLabelers(); const requestTime = Date.now(); - this.dataStore.$latestChatRecipientSearchRequestTime.set(requestTime); + $latestRequestTime.set(requestTime); const searchData = await this.api.searchProfilesTypeahead(query, { limit, labelers, }); - if ( - requestTime !== this.dataStore.$latestChatRecipientSearchRequestTime.get() - ) { + if (requestTime !== $latestRequestTime.get()) { return; } this.dataStore.setProfiles(searchData.actors); - this.dataStore.$chatRecipientSearchResults.set(searchData); + $results.set(searchData); + } + + async loadChatRecipientSearch(query, { limit = 12 } = {}) { + await this._loadProfileTypeahead(query, { + limit, + $results: this.dataStore.$chatRecipientSearchResults, + $latestRequestTime: this.dataStore.$latestChatRecipientSearchRequestTime, + }); } async loadSearchTypeahead(query, { limit = 8 } = {}) { - if (!query) { - // Invalidate in-flight searches so they can't repopulate cleared results - this.dataStore.$latestSearchTypeaheadRequestTime.set(null); - this.dataStore.$searchTypeaheadResults.set(null); - return; - } - const labelers = this.requireLabelers(); - const requestTime = Date.now(); - this.dataStore.$latestSearchTypeaheadRequestTime.set(requestTime); - const searchData = await this.api.searchProfilesTypeahead(query, { + await this._loadProfileTypeahead(query, { limit, - labelers, + $results: this.dataStore.$searchTypeaheadResults, + $latestRequestTime: this.dataStore.$latestSearchTypeaheadRequestTime, + }); + } + + async loadSidebarSearchTypeahead(query, { limit = 8 } = {}) { + await this._loadProfileTypeahead(query, { + limit, + $results: this.dataStore.$sidebarSearchTypeaheadResults, + $latestRequestTime: + this.dataStore.$latestSidebarSearchTypeaheadRequestTime, }); - if ( - requestTime !== this.dataStore.$latestSearchTypeaheadRequestTime.get() - ) { - return; - } - this.dataStore.setProfiles(searchData.actors); - this.dataStore.$searchTypeaheadResults.set(searchData); } async loadPostSearchTop(query, { limit = 25, cursor = "" } = {}) { diff --git a/src/js/mainLayout.js b/src/js/mainLayout.js index f2ee2821..5a9a288b 100644 --- a/src/js/mainLayout.js +++ b/src/js/mainLayout.js @@ -52,6 +52,7 @@ function pluginPreviewBannerTemplate({ plugins }) { import { Layout } from "/js/router.js"; import "/js/components/animated-sidebar.js"; import "/js/components/trending-pane.js"; +import "/js/components/sidebar-search.js"; export function mainLayoutTemplate({ isAuthenticated = true, @@ -97,6 +98,12 @@ export function mainLayoutTemplate({ ${children}
+ ${activeNavItem === "search" + ? "" + : html``}
diff --git a/src/js/views/search.view.js b/src/js/views/search.view.js index b7be4ed7..04615302 100644 --- a/src/js/views/search.view.js +++ b/src/js/views/search.view.js @@ -286,7 +286,7 @@ export default async function searchView({ @click=${() => onCommit()} >
${searchIconTemplate()}
-
Search for "${query}"
+
${query}
${profiles === null ? html`
diff --git a/tests/e2e/specs/concerns/sidebarSearch.test.js b/tests/e2e/specs/concerns/sidebarSearch.test.js new file mode 100644 index 00000000..13665d0a --- /dev/null +++ b/tests/e2e/specs/concerns/sidebarSearch.test.js @@ -0,0 +1,89 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { createProfile } from "../../../shared/factories.js"; + +test.describe("Sidebar search", () => { + let mockServer; + + test.beforeEach(async ({ page }) => { + mockServer = new MockServer(); + mockServer.addTypeaheadProfiles([ + createProfile({ + did: "did:plc:profile1", + handle: "alice.bsky.social", + displayName: "Alice", + }), + ]); + mockServer.addSearchProfiles([ + createProfile({ + did: "did:plc:profile1", + handle: "alice.bsky.social", + displayName: "Alice", + }), + ]); + await mockServer.setup(page); + await login(page); + }); + + test("shows typeahead results in the right column", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); + + const input = page.locator('[data-testid="sidebar-search-input"]'); + await expect(input).toBeVisible(); + await input.fill("ali"); + + const typeahead = page.locator('[data-testid="sidebar-search-typeahead"]'); + await expect(typeahead).toBeVisible(); + await expect( + typeahead.locator('[data-testid="sidebar-search-typeahead-result"]'), + ).toHaveCount(1); + await expect(typeahead).toContainText("@alice.bsky.social"); + }); + + test("navigates to the search page on Enter", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); + + await page.locator('[data-testid="sidebar-search-input"]').fill("ali"); + await page.locator('[data-testid="sidebar-search-input"]').press("Enter"); + + await expect(page).toHaveURL(/\/search\?q=ali/); + await expect(page.locator("#search-view")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("#search-view .search-input")).toHaveValue("ali"); + await page.locator('#search-view [data-testid="tab-profiles"]').click(); + await expect(page.locator("#search-view .profile-list-item")).toHaveCount( + 1, + { timeout: 10000 }, + ); + }); + + test("navigates to a profile from a typeahead result", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); + + await page.locator('[data-testid="sidebar-search-input"]').fill("ali"); + await page + .locator('[data-testid="sidebar-search-typeahead-result"]') + .first() + .click(); + + await expect(page).toHaveURL(/\/profile\/alice\.bsky\.social/); + await expect( + page.locator('[data-testid="sidebar-search-input"]'), + ).toHaveValue(""); + await expect( + page.locator('[data-testid="sidebar-search-typeahead"]'), + ).toHaveCount(0); + }); + + test("is hidden on the search page itself", async ({ page }) => { + await page.goto("/search"); + await expect(page.locator("#search-view")).toBeVisible({ timeout: 10000 }); + + await expect( + page.locator('[data-testid="sidebar-search-input"]'), + ).toHaveCount(0); + }); +}); diff --git a/tests/unit/specs/components/sidebar-search.test.js b/tests/unit/specs/components/sidebar-search.test.js new file mode 100644 index 00000000..3a5a0107 --- /dev/null +++ b/tests/unit/specs/components/sidebar-search.test.js @@ -0,0 +1,272 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import "/js/components/sidebar-search.js"; +import { makeTestDataLayer } from "../../testHelpers.js"; +import { createProfile } from "../../../shared/factories.js"; + +describe("sidebar-search", () => { + const alice = createProfile({ + did: "did:plc:alice", + handle: "alice.test", + displayName: "Alice", + }); + + let originalRouter; + let navigations; + + async function flush() { + // Two ticks: effect re-renders are scheduled, and the typeahead load + // resolves a microtask before the render it triggers. + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + async function mount({ actors = [alice], isAuthenticated = false } = {}) { + const searchProfilesTypeahead = mock.fn(async () => ({ actors })); + const dataLayer = makeTestDataLayer({ api: { searchProfilesTypeahead } }); + await dataLayer.preferencesProvider.fetchPreferences(); + const element = document.createElement("sidebar-search"); + element.dataLayer = dataLayer; + element.isAuthenticated = isAuthenticated; + document.body.appendChild(element); + return { element, dataLayer, searchProfilesTypeahead }; + } + + function input(element) { + return element.querySelector("[data-testid='sidebar-search-input']"); + } + + async function type(element, value) { + const field = input(element); + field.value = value; + field.dispatchEvent(new window.InputEvent("input", { bubbles: true })); + await flush(); + } + + function pressKey(element, key) { + input(element).dispatchEvent( + new window.KeyboardEvent("keydown", { key, bubbles: true }), + ); + } + + beforeEach(() => { + document.body.innerHTML = ""; + navigations = []; + originalRouter = window.router; + window.router = { go: (href) => navigations.push(href) }; + }); + + afterEach(() => { + document.body.innerHTML = ""; + window.router = originalRouter; + }); + + it("renders a search input with no typeahead until the user types", async () => { + const { element } = await mount(); + + assert(input(element) !== null); + assert.deepEqual( + element.querySelector("[data-testid='sidebar-search-typeahead']"), + null, + ); + }); + + it("shows typeahead profile results while typing", async () => { + const { element, searchProfilesTypeahead } = await mount(); + + await type(element, "ali"); + + assert.deepEqual(searchProfilesTypeahead.mock.calls[0].arguments[0], "ali"); + const typeahead = element.querySelector( + "[data-testid='sidebar-search-typeahead']", + ); + assert(typeahead !== null); + assert.deepEqual( + typeahead + .querySelector("[data-testid='sidebar-search-typeahead-search-row']") + .textContent.trim(), + "ali", + ); + const results = typeahead.querySelectorAll( + "[data-testid='sidebar-search-typeahead-result']", + ); + assert.deepEqual(results.length, 1); + assert.deepEqual(results[0].getAttribute("href"), "/profile/alice.test"); + }); + + it("keeps its results out of the search view's typeahead store", async () => { + const { element, dataLayer } = await mount(); + dataLayer.dataStore.$searchTypeaheadResults.set({ + actors: [{ did: "did:plc:searchview" }], + }); + + await type(element, "ali"); + + assert.deepEqual( + dataLayer.dataStore.$sidebarSearchTypeaheadResults.get().actors[0].did, + "did:plc:alice", + ); + assert.deepEqual( + dataLayer.dataStore.$searchTypeaheadResults.get().actors[0].did, + "did:plc:searchview", + ); + }); + + it("navigates to the search page on Enter", async () => { + const { element } = await mount(); + + await type(element, " cats and dogs "); + pressKey(element, "Enter"); + + assert.deepEqual(navigations, ["/search?q=cats+and+dogs"]); + }); + + it("navigates to the search page when the search row is clicked", async () => { + const { element } = await mount(); + + await type(element, "cats"); + element + .querySelector("[data-testid='sidebar-search-typeahead-search-row']") + .click(); + await flush(); + + assert.deepEqual(navigations, ["/search?q=cats"]); + assert.deepEqual( + element.querySelector("[data-testid='sidebar-search-typeahead']"), + null, + ); + }); + + it("clears and unfocuses the input after committing a search", async () => { + const { element, dataLayer } = await mount(); + + input(element).focus(); + await type(element, "cats"); + pressKey(element, "Enter"); + await flush(); + + assert.deepEqual(input(element).value, ""); + assert(document.activeElement !== input(element)); + assert.deepEqual( + dataLayer.dataStore.$sidebarSearchTypeaheadResults.get(), + null, + ); + }); + + it("clears and unfocuses the input after selecting a profile", async () => { + const { element, dataLayer } = await mount(); + + input(element).focus(); + await type(element, "ali"); + element + .querySelector("[data-testid='sidebar-search-typeahead-result']") + .click(); + await flush(); + + assert.deepEqual(input(element).value, ""); + assert(document.activeElement !== input(element)); + assert.deepEqual( + element.querySelector("[data-testid='sidebar-search-typeahead']"), + null, + ); + assert.deepEqual( + dataLayer.dataStore.$sidebarSearchTypeaheadResults.get(), + null, + ); + }); + + it("does not navigate when the query is blank", async () => { + const { element } = await mount(); + + await type(element, " "); + pressKey(element, "Enter"); + + assert.deepEqual(navigations, []); + assert.deepEqual( + element.querySelector("[data-testid='sidebar-search-typeahead']"), + null, + ); + }); + + it("clears the input and typeahead with the clear button", async () => { + const { element, dataLayer } = await mount(); + + await type(element, "cats"); + element + .querySelector("[data-testid='sidebar-search-clear-button']") + .click(); + await flush(); + + assert.deepEqual(input(element).value, ""); + assert.deepEqual( + element.querySelector("[data-testid='sidebar-search-typeahead']"), + null, + ); + assert.deepEqual( + dataLayer.dataStore.$sidebarSearchTypeaheadResults.get(), + null, + ); + }); + + it("clears the input on Escape", async () => { + const { element } = await mount(); + + await type(element, "cats"); + pressKey(element, "Escape"); + await flush(); + + assert.deepEqual(input(element).value, ""); + assert.deepEqual( + element.querySelector("[data-testid='sidebar-search-typeahead']"), + null, + ); + }); + + it("hides the typeahead on blur and restores it on focus", async () => { + const { element } = await mount(); + + await type(element, "cats"); + input(element).dispatchEvent(new window.FocusEvent("blur")); + await flush(); + assert.deepEqual( + element.querySelector("[data-testid='sidebar-search-typeahead']"), + null, + ); + + input(element).dispatchEvent(new window.FocusEvent("focus")); + await flush(); + assert( + element.querySelector("[data-testid='sidebar-search-typeahead']") !== + null, + ); + }); + + it("records a recent search when authenticated", async () => { + const { element, dataLayer } = await mount({ isAuthenticated: true }); + const addRecentSearch = mock.method( + dataLayer.mutations, + "addRecentSearch", + async () => {}, + ); + + await type(element, "cats"); + pressKey(element, "Enter"); + + assert.deepEqual(addRecentSearch.mock.calls[0].arguments[0], "cats"); + }); + + it("does not record a recent search when logged out", async () => { + const { element, dataLayer } = await mount({ isAuthenticated: false }); + const addRecentSearch = mock.method( + dataLayer.mutations, + "addRecentSearch", + async () => {}, + ); + + await type(element, "cats"); + pressKey(element, "Enter"); + + assert.deepEqual(addRecentSearch.mock.calls.length, 0); + assert.deepEqual(navigations, ["/search?q=cats"]); + }); +}); diff --git a/tests/unit/specs/dataLayer/requests.test.js b/tests/unit/specs/dataLayer/requests.test.js index e93bbc0b..524153e6 100644 --- a/tests/unit/specs/dataLayer/requests.test.js +++ b/tests/unit/specs/dataLayer/requests.test.js @@ -1266,6 +1266,82 @@ describe("loadSearchTypeahead", () => { }); }); +describe("loadSidebarSearchTypeahead", () => { + it("should store the search results and hydrate profiles", async () => { + const dataStore = new DataStore(); + const mockApi = { + searchProfilesTypeahead: async () => ({ + actors: [{ did: "did:plc:a" }], + }), + }; + const requests = makeRequests(mockApi, dataStore); + + await requests.loadSidebarSearchTypeahead("alice"); + + const stored = dataStore.$sidebarSearchTypeaheadResults.get(); + assert.deepEqual(stored.actors.length, 1); + assert.deepEqual(stored.actors[0].did, "did:plc:a"); + assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { + did: "did:plc:a", + }); + }); + + it("should clear results when query is empty", async () => { + const dataStore = new DataStore(); + dataStore.$sidebarSearchTypeaheadResults.set({ actors: [{ did: "x" }] }); + const mockApi = { + searchProfilesTypeahead: async () => ({ actors: [] }), + }; + const requests = makeRequests(mockApi, dataStore); + + await requests.loadSidebarSearchTypeahead(""); + + assert.deepEqual(dataStore.$sidebarSearchTypeaheadResults.get(), null); + }); + + it("should discard in-flight responses after the query is cleared", async () => { + const dataStore = new DataStore(); + let resolveSearch; + const searchPromise = new Promise((resolve) => { + resolveSearch = resolve; + }); + const mockApi = { + searchProfilesTypeahead: async () => { + await searchPromise; + return { actors: [{ did: "stale" }] }; + }, + }; + const requests = makeRequests(mockApi, dataStore); + + const inFlight = requests.loadSidebarSearchTypeahead("query"); + await requests.loadSidebarSearchTypeahead(""); + resolveSearch(); + await inFlight; + + assert.deepEqual(dataStore.$sidebarSearchTypeaheadResults.get(), null); + }); + + it("should not disturb the search view's typeahead results", async () => { + const dataStore = new DataStore(); + const mockApi = { + searchProfilesTypeahead: async () => ({ + actors: [{ did: "did:plc:sidebar" }], + }), + }; + const requests = makeRequests(mockApi, dataStore); + await requests.loadSearchTypeahead("alice"); + + await requests.loadSidebarSearchTypeahead("bob"); + await requests.loadSidebarSearchTypeahead(""); + + assert.deepEqual(dataStore.$sidebarSearchTypeaheadResults.get(), null); + assert.deepEqual( + dataStore.$searchTypeaheadResults.get().actors[0].did, + "did:plc:sidebar", + ); + }); +}); + describe("loadFeedSearch", () => { it("should clear results when query is empty", async () => { const dataStore = new DataStore(); diff --git a/tests/unit/specs/mainLayout.test.js b/tests/unit/specs/mainLayout.test.js index 30006373..3027fa76 100644 --- a/tests/unit/specs/mainLayout.test.js +++ b/tests/unit/specs/mainLayout.test.js @@ -125,6 +125,27 @@ describe("MainLayout", () => { ); }); + it("mounts the sidebar search above trending, except on the search page", async () => { + setRoute({ layoutOptions: { activeNavItem: "home" } }); + await flushRender(); + + const rightColumn = harness.appRoot.querySelector(".view-column-right"); + const elements = [...rightColumn.children].map((child) => + child.tagName.toLowerCase(), + ); + assert.deepEqual(elements, ["sidebar-search", "trending-pane"]); + + setRoute({ layoutOptions: { activeNavItem: "search" } }); + await flushRender(); + + assert.deepEqual( + harness.appRoot + .querySelector(".view-column-right") + .querySelector("sidebar-search"), + null, + ); + }); + it("derives the active nav item from the current route options", async () => { setRoute({ layoutOptions: { activeNavItem: "home" } }); await flushRender(); -- 2.51.2