mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
feat(desktop): prioritize active conversation search
Co-authored-by: Fizz <f2701065ae39f701786d464e4c06b5fbc2fa3dda9f31f9439b3d077d8fd7fb0d@buzz.block.builderlab.xyz> Signed-off-by: Fizz <f2701065ae39f701786d464e4c06b5fbc2fa3dda9f31f9439b3d077d8fd7fb0d@buzz.block.builderlab.xyz> Co-authored-by: Arjun Mahanti <arjun@squareup.com> Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
@@ -92,56 +92,3 @@ export function SearchDialogInputRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CurrentChannelSearchActionProps = {
|
||||
channelLabel: string;
|
||||
channelType: Channel["channelType"];
|
||||
isSelected: boolean;
|
||||
menuIndex: number;
|
||||
onActivate: () => void;
|
||||
onMouseEnter: () => void;
|
||||
};
|
||||
|
||||
export function CurrentChannelSearchAction({
|
||||
channelLabel,
|
||||
channelType,
|
||||
isSelected,
|
||||
menuIndex,
|
||||
onActivate,
|
||||
onMouseEnter,
|
||||
}: CurrentChannelSearchActionProps) {
|
||||
const isDirectMessage = channelType === "dm";
|
||||
|
||||
return (
|
||||
<div className="px-3 py-3.5">
|
||||
<button
|
||||
aria-selected={isSelected}
|
||||
className={`flex w-full items-center gap-4 rounded-lg border border-border/75 px-3 py-3 text-left transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
isSelected ? "bg-muted/55" : "bg-muted/30 hover:bg-muted/55"
|
||||
}`}
|
||||
data-search-result-index={menuIndex}
|
||||
data-testid="search-current-channel-control"
|
||||
onClick={onActivate}
|
||||
onMouseEnter={onMouseEnter}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0" />
|
||||
<span
|
||||
className="min-w-0 truncate text-sm text-muted-foreground"
|
||||
data-testid="search-current-scope-label"
|
||||
>
|
||||
{isDirectMessage ? "Search conversation with " : "Search in "}
|
||||
<span className="font-medium text-foreground">{channelLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground/70">
|
||||
{isDirectMessage
|
||||
? "Search messages in this conversation."
|
||||
: "Search messages in this channel"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type SearchResult,
|
||||
} from "@/features/search/ui/SearchResultItem";
|
||||
import {
|
||||
CurrentChannelSearchAction,
|
||||
getChannelScopeLabel,
|
||||
SearchDialogInputRow,
|
||||
} from "@/features/search/ui/SearchScopeControls";
|
||||
@@ -19,7 +18,6 @@ import { SearchResultTrailing } from "@/features/search/ui/SearchResultTrailing"
|
||||
import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation";
|
||||
import {
|
||||
getChannelActivityTime,
|
||||
getSearchActionMenuIndex,
|
||||
getSuggestedSearchResults,
|
||||
} from "@/features/search/ui/topbarSearchSuggestions";
|
||||
import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types";
|
||||
@@ -60,6 +58,7 @@ const EMPTY_UNREAD_CHANNEL_COUNTS: ReadonlyMap<string, number> = new Map(),
|
||||
const SEARCH_SECTION_TITLE_CLASS =
|
||||
"px-2.5 pb-1.5 pt-2 text-xs font-medium text-muted-foreground/70";
|
||||
const SEARCH_RESULT_SECTION_ORDER = [
|
||||
"current-channel-messages",
|
||||
"channels",
|
||||
"direct-messages",
|
||||
"people",
|
||||
@@ -217,7 +216,14 @@ function getSearchHitContextLabel(
|
||||
};
|
||||
}
|
||||
|
||||
function getResultSectionKey(result: SearchResult): SearchResultSectionKey {
|
||||
function getResultSectionKey(
|
||||
result: SearchResult,
|
||||
currentChannelId?: string | null,
|
||||
): SearchResultSectionKey {
|
||||
if (result.kind === "message" && result.hit.channelId === currentChannelId) {
|
||||
return "current-channel-messages";
|
||||
}
|
||||
|
||||
if (result.kind === "channel") {
|
||||
return result.channel.channelType === "dm" ? "direct-messages" : "channels";
|
||||
}
|
||||
@@ -235,6 +241,8 @@ function getResultSectionKey(result: SearchResult): SearchResultSectionKey {
|
||||
|
||||
function getSectionTitle(sectionKey: SearchResultSectionKey) {
|
||||
switch (sectionKey) {
|
||||
case "current-channel-messages":
|
||||
return "In this conversation";
|
||||
case "channels":
|
||||
return "Channels";
|
||||
case "direct-messages":
|
||||
@@ -274,11 +282,14 @@ function SearchHitContextLine({ label }: { label: SearchHitContextLabel }) {
|
||||
);
|
||||
}
|
||||
|
||||
function groupSearchResults(results: SearchResult[]): SearchResultSection[] {
|
||||
function groupSearchResults(
|
||||
results: SearchResult[],
|
||||
currentChannelId?: string | null,
|
||||
): SearchResultSection[] {
|
||||
const resultsBySection = new Map<SearchResultSectionKey, SearchResult[]>();
|
||||
|
||||
for (const result of results) {
|
||||
const sectionKey = getResultSectionKey(result);
|
||||
const sectionKey = getResultSectionKey(result, currentChannelId);
|
||||
const sectionResults = resultsBySection.get(sectionKey) ?? [];
|
||||
sectionResults.push(result);
|
||||
resultsBySection.set(sectionKey, sectionResults);
|
||||
@@ -390,6 +401,7 @@ export function TopbarSearch({
|
||||
debouncedQuery,
|
||||
fuzzyUserCandidatesQuery,
|
||||
isWaitingOnFromResolution,
|
||||
prioritizedChannelSearchQuery,
|
||||
query,
|
||||
resultProfiles,
|
||||
results,
|
||||
@@ -401,13 +413,11 @@ export function TopbarSearch({
|
||||
channels,
|
||||
enabled: isOpen,
|
||||
limit: SEARCH_RESULT_LIMIT,
|
||||
prioritizedChannelId: scopeChannelId ? null : currentChannelId,
|
||||
scopeChannelId,
|
||||
});
|
||||
const trimmedQuery = query.trim();
|
||||
const isIconVariant = variant === "icon";
|
||||
const currentChannel = currentChannelId
|
||||
? (channelLookup.get(currentChannelId) ?? null)
|
||||
: null;
|
||||
const scopeChannel = scopeChannelId
|
||||
? (channelLookup.get(scopeChannelId) ?? null)
|
||||
: null;
|
||||
@@ -416,7 +426,6 @@ export function TopbarSearch({
|
||||
: null;
|
||||
const currentPubkeyNormalized =
|
||||
currentPubkey && normalizePubkey(currentPubkey);
|
||||
const hasScopeAction = Boolean(currentChannel && !scopeChannel);
|
||||
const { suggestedResults, unreadResults } = React.useMemo(
|
||||
() =>
|
||||
getSuggestedSearchResults(
|
||||
@@ -477,8 +486,12 @@ export function TopbarSearch({
|
||||
[currentPubkeyNormalized, results],
|
||||
);
|
||||
const searchResultSections = React.useMemo(
|
||||
() => groupSearchResults(searchableResults),
|
||||
[searchableResults],
|
||||
() =>
|
||||
groupSearchResults(
|
||||
searchableResults,
|
||||
scopeChannel ? null : currentChannelId,
|
||||
),
|
||||
[currentChannelId, scopeChannel, searchableResults],
|
||||
);
|
||||
const groupedSearchResults = React.useMemo(
|
||||
() => searchResultSections.flatMap((section) => section.results),
|
||||
@@ -489,14 +502,10 @@ export function TopbarSearch({
|
||||
? []
|
||||
: suggestionResults
|
||||
: groupedSearchResults;
|
||||
const actionMenuIndex = getSearchActionMenuIndex({
|
||||
hasScopeAction,
|
||||
isShowingSuggestions,
|
||||
unreadResultCount: unreadResults.length,
|
||||
});
|
||||
const isSearchLoading =
|
||||
isWaitingOnFromResolution ||
|
||||
searchQuery.isLoading ||
|
||||
prioritizedChannelSearchQuery.isLoading ||
|
||||
fuzzyUserCandidatesQuery.isLoading ||
|
||||
userSearchQuery.isLoading;
|
||||
|
||||
@@ -599,13 +608,6 @@ export function TopbarSearch({
|
||||
window.requestAnimationFrame(() => dialogInputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const activateCurrentChannelScope = React.useCallback(() => {
|
||||
if (!currentChannel) return;
|
||||
setScopeChannelId(currentChannel.id);
|
||||
setSelectedMenuIndex(0);
|
||||
focusDialogInput();
|
||||
}, [currentChannel, focusDialogInput]);
|
||||
|
||||
const removeChannelScope = React.useCallback(() => {
|
||||
setScopeChannelId(null);
|
||||
setSelectedMenuIndex(0);
|
||||
@@ -627,9 +629,7 @@ export function TopbarSearch({
|
||||
}, [isOpen]);
|
||||
|
||||
const handleDialogInputKeyDown = useSearchMenuKeyboardNavigation({
|
||||
actionMenuIndex,
|
||||
activeResults,
|
||||
onActivateAction: activateCurrentChannelScope,
|
||||
onOpenResult: openResult,
|
||||
onRemoveScope: removeChannelScope,
|
||||
query,
|
||||
@@ -638,9 +638,7 @@ export function TopbarSearch({
|
||||
setSelectedMenuIndex,
|
||||
});
|
||||
|
||||
const renderSearchResultRow = (result: SearchResult, index: number) => {
|
||||
const menuIndex =
|
||||
actionMenuIndex !== null && index >= actionMenuIndex ? index + 1 : index;
|
||||
const renderSearchResultRow = (result: SearchResult, menuIndex: number) => {
|
||||
const channelDisplayName =
|
||||
result.kind === "channel"
|
||||
? getChannelDisplayName(result.channel, channelLabels)
|
||||
@@ -795,31 +793,10 @@ export function TopbarSearch({
|
||||
</div>
|
||||
));
|
||||
};
|
||||
const currentChannelSearchAction =
|
||||
currentChannel && !scopeChannel ? (
|
||||
<CurrentChannelSearchAction
|
||||
channelLabel={getChannelScopeLabel(
|
||||
currentChannel,
|
||||
channelLabels,
|
||||
currentPubkey,
|
||||
)}
|
||||
channelType={currentChannel.channelType}
|
||||
isSelected={selectedMenuIndex === actionMenuIndex}
|
||||
menuIndex={actionMenuIndex ?? 0}
|
||||
onActivate={activateCurrentChannelScope}
|
||||
onMouseEnter={() => setSelectedMenuIndex(actionMenuIndex ?? 0)}
|
||||
/>
|
||||
) : null;
|
||||
const searchResultContent = isShowingSuggestions ? (
|
||||
scopeChannel ? null : suggestionResults.length === 0 ? (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{currentChannelSearchAction}
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 text-sm text-muted-foreground",
|
||||
currentChannelSearchAction ? "pb-5" : "py-5",
|
||||
)}
|
||||
>
|
||||
<div className="px-4 py-5 text-sm text-muted-foreground">
|
||||
<p>No recent activity yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -843,7 +820,6 @@ export function TopbarSearch({
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{currentChannelSearchAction}
|
||||
{suggestedResults.length > 0 ? (
|
||||
<div data-search-section="recent-activity">
|
||||
<div className={SEARCH_SECTION_TITLE_CLASS}>
|
||||
@@ -870,30 +846,17 @@ export function TopbarSearch({
|
||||
)
|
||||
) : isSearchLoading && searchableResults.length === 0 ? (
|
||||
<div className="max-h-[min(60vh,32rem)] overflow-y-auto">
|
||||
{currentChannelSearchAction}
|
||||
<SearchResultsSkeleton />
|
||||
</div>
|
||||
) : searchQuery.error instanceof Error && searchableResults.length === 0 ? (
|
||||
<div className="max-h-[min(60vh,32rem)] overflow-y-auto">
|
||||
{currentChannelSearchAction}
|
||||
<p
|
||||
className={cn(
|
||||
"px-4 text-sm text-destructive",
|
||||
currentChannelSearchAction ? "pb-5" : "py-5",
|
||||
)}
|
||||
>
|
||||
<p className="px-4 py-5 text-sm text-destructive">
|
||||
{searchQuery.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : searchableResults.length === 0 ? (
|
||||
<div className="max-h-[min(60vh,32rem)] overflow-y-auto">
|
||||
{currentChannelSearchAction}
|
||||
<p
|
||||
className={cn(
|
||||
"px-4 text-sm text-muted-foreground",
|
||||
currentChannelSearchAction ? "pb-5" : "py-5",
|
||||
)}
|
||||
>
|
||||
<p className="px-4 py-5 text-sm text-muted-foreground">
|
||||
No {scopeChannel ? "messages" : "matches"} for{" "}
|
||||
<span className="font-semibold">{trimmedQuery}</span>
|
||||
{scopeLabel ? (
|
||||
@@ -911,7 +874,6 @@ export function TopbarSearch({
|
||||
data-testid="search-results-list"
|
||||
role="listbox"
|
||||
>
|
||||
{currentChannelSearchAction}
|
||||
<div className="p-1.5">
|
||||
{renderSearchResultSections(searchResultSections)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getSearchActionMenuIndex,
|
||||
getSuggestedChannels,
|
||||
} from "./topbarSearchSuggestions.ts";
|
||||
import { getSuggestedChannels } from "./topbarSearchSuggestions.ts";
|
||||
|
||||
function channel(id, options = {}) {
|
||||
return {
|
||||
@@ -72,30 +69,3 @@ test("archived and unjoined non-DM conversations are omitted", () => {
|
||||
["dm"],
|
||||
);
|
||||
});
|
||||
|
||||
test("the scope action follows unread rows while suggestions are visible", () => {
|
||||
assert.equal(
|
||||
getSearchActionMenuIndex({
|
||||
hasScopeAction: true,
|
||||
isShowingSuggestions: true,
|
||||
unreadResultCount: 2,
|
||||
}),
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
getSearchActionMenuIndex({
|
||||
hasScopeAction: true,
|
||||
isShowingSuggestions: true,
|
||||
unreadResultCount: 0,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
getSearchActionMenuIndex({
|
||||
hasScopeAction: false,
|
||||
isShowingSuggestions: true,
|
||||
unreadResultCount: 2,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,16 +64,3 @@ export function getSuggestedSearchResults(
|
||||
unreadResults: unreadChannels.map(toResult),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSearchActionMenuIndex({
|
||||
hasScopeAction,
|
||||
isShowingSuggestions,
|
||||
unreadResultCount,
|
||||
}: {
|
||||
hasScopeAction: boolean;
|
||||
isShowingSuggestions: boolean;
|
||||
unreadResultCount: number;
|
||||
}) {
|
||||
if (!hasScopeAction) return null;
|
||||
return isShowingSuggestions && unreadResultCount > 0 ? unreadResultCount : 0;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ import * as React from "react";
|
||||
import type { SearchResult } from "@/features/search/ui/SearchResultItem";
|
||||
|
||||
export function useSearchMenuKeyboardNavigation({
|
||||
actionMenuIndex,
|
||||
activeResults,
|
||||
onActivateAction,
|
||||
onOpenResult,
|
||||
onRemoveScope,
|
||||
query,
|
||||
@@ -13,9 +11,7 @@ export function useSearchMenuKeyboardNavigation({
|
||||
selectedMenuIndex,
|
||||
setSelectedMenuIndex,
|
||||
}: {
|
||||
actionMenuIndex: number | null;
|
||||
activeResults: SearchResult[];
|
||||
onActivateAction: () => void;
|
||||
onOpenResult: (result: SearchResult) => void;
|
||||
onRemoveScope: () => void;
|
||||
query: string;
|
||||
@@ -23,8 +19,7 @@ export function useSearchMenuKeyboardNavigation({
|
||||
selectedMenuIndex: number;
|
||||
setSelectedMenuIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
}) {
|
||||
const selectableCount =
|
||||
activeResults.length + (actionMenuIndex === null ? 0 : 1);
|
||||
const selectableCount = activeResults.length;
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedMenuIndex((current) => {
|
||||
@@ -64,22 +59,12 @@ export function useSearchMenuKeyboardNavigation({
|
||||
|
||||
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
if (actionMenuIndex === selectedMenuIndex) {
|
||||
onActivateAction();
|
||||
return;
|
||||
}
|
||||
const resultIndex =
|
||||
actionMenuIndex !== null && selectedMenuIndex > actionMenuIndex
|
||||
? selectedMenuIndex - 1
|
||||
: selectedMenuIndex;
|
||||
const result = activeResults[resultIndex];
|
||||
const result = activeResults[selectedMenuIndex];
|
||||
if (result) onOpenResult(result);
|
||||
}
|
||||
},
|
||||
[
|
||||
actionMenuIndex,
|
||||
activeResults,
|
||||
onActivateAction,
|
||||
onOpenResult,
|
||||
onRemoveScope,
|
||||
query.length,
|
||||
|
||||
@@ -104,12 +104,14 @@ export function useSearchResults({
|
||||
channels: memberChannels,
|
||||
enabled,
|
||||
limit = 12,
|
||||
prioritizedChannelId,
|
||||
scopeChannelId,
|
||||
}: {
|
||||
channelLabels?: Record<string, string>;
|
||||
channels: Channel[];
|
||||
enabled: boolean;
|
||||
limit?: number;
|
||||
prioritizedChannelId?: string | null;
|
||||
scopeChannelId?: string | null;
|
||||
}) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
@@ -255,13 +257,40 @@ export function useSearchResults({
|
||||
unresolvedOperator: hasUnresolvedOperator,
|
||||
minimumQueryLength,
|
||||
});
|
||||
const prioritizedChannelSearchQuery = useSearchMessagesQuery(ftsQuery, {
|
||||
enabled:
|
||||
enabled &&
|
||||
!scopeChannelId &&
|
||||
Boolean(prioritizedChannelId) &&
|
||||
channelResolution.status === "none" &&
|
||||
!hasUnresolvedOperator &&
|
||||
!waitingOnFromResolution &&
|
||||
ftsQuery.length >= minimumQueryLength,
|
||||
limit,
|
||||
channelId: prioritizedChannelId ?? undefined,
|
||||
authors:
|
||||
authorResolution.status === "resolved"
|
||||
? [authorResolution.value]
|
||||
: undefined,
|
||||
since: parsedQuery.since,
|
||||
until: parsedQuery.until,
|
||||
unresolvedOperator: hasUnresolvedOperator,
|
||||
minimumQueryLength,
|
||||
});
|
||||
|
||||
const messageResults = React.useMemo(() => {
|
||||
if (hasUnresolvedOperator) {
|
||||
return [];
|
||||
}
|
||||
return dedupeSearchHits(searchQuery.data?.hits ?? []);
|
||||
}, [hasUnresolvedOperator, searchQuery.data?.hits]);
|
||||
return dedupeSearchHits([
|
||||
...(prioritizedChannelSearchQuery.data?.hits ?? []),
|
||||
...(searchQuery.data?.hits ?? []),
|
||||
]);
|
||||
}, [
|
||||
hasUnresolvedOperator,
|
||||
prioritizedChannelSearchQuery.data?.hits,
|
||||
searchQuery.data?.hits,
|
||||
]);
|
||||
const channelResults = React.useMemo(() => {
|
||||
if (scopeChannelId || ftsQuery.length < MIN_SEARCH_QUERY_LENGTH) {
|
||||
return [];
|
||||
@@ -498,6 +527,7 @@ export function useSearchResults({
|
||||
isWaitingOnFromResolution: waitingOnFromResolution,
|
||||
messageResults,
|
||||
query,
|
||||
prioritizedChannelSearchQuery,
|
||||
resultProfiles: resultProfilesQuery.data?.profiles,
|
||||
results,
|
||||
searchQuery,
|
||||
|
||||
@@ -441,9 +441,9 @@ test("Cmd+K bubbles unread conversations to the top and Enter opens the first",
|
||||
page.getByTestId(`search-unread-count-${directMessageId}`),
|
||||
).toHaveText("2");
|
||||
await expect(firstUnread.getByText("Enter", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("search-current-channel-control"),
|
||||
).toHaveAttribute("data-search-result-index", "1");
|
||||
await expect(page.getByTestId("search-current-channel-control")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(
|
||||
@@ -452,146 +452,29 @@ test("Cmd+K bubbles unread conversations to the top and Enter opens the first",
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
||||
});
|
||||
|
||||
test("global search offers an optional current-channel scope", async ({
|
||||
test("global search prioritizes messages from the current channel", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page).toHaveURL(
|
||||
/#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50$/,
|
||||
);
|
||||
|
||||
await focusSidebarSearchWithShortcut(page);
|
||||
|
||||
const scopeControl = page.getByTestId("search-current-channel-control");
|
||||
const input = page.getByTestId("search-dialog-input");
|
||||
await expect
|
||||
.poll(() =>
|
||||
scopeControl
|
||||
.getByTestId("search-current-scope-label")
|
||||
.evaluate((element) => element.textContent),
|
||||
)
|
||||
.toBe("Search in #general");
|
||||
await expect(scopeControl).toContainText("Search in");
|
||||
await expect(scopeControl).toContainText("#general");
|
||||
await expect(scopeControl).toContainText("Search messages in this channel");
|
||||
await expect(page.getByTestId("search-dialog-input-row")).toHaveCSS(
|
||||
"border-bottom-width",
|
||||
"1px",
|
||||
await expect(page.getByTestId("search-current-channel-control")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(scopeControl.locator("..")).toHaveCSS(
|
||||
"border-bottom-width",
|
||||
"0px",
|
||||
);
|
||||
await expect(scopeControl.locator("..")).toHaveCSS("padding-top", "14px");
|
||||
await expect(scopeControl.locator("..")).toHaveCSS("padding-bottom", "14px");
|
||||
const [controlBox, dialogBox] = await Promise.all([
|
||||
scopeControl.boundingBox(),
|
||||
page.getByTestId("search-results").boundingBox(),
|
||||
]);
|
||||
expect(controlBox).not.toBeNull();
|
||||
expect(dialogBox).not.toBeNull();
|
||||
expect(controlBox?.width ?? 0).toBeGreaterThan((dialogBox?.width ?? 0) * 0.9);
|
||||
expect(controlBox?.width ?? 0).toBeLessThan(dialogBox?.width ?? 0);
|
||||
await expect(scopeControl).toHaveAttribute("aria-selected", "true");
|
||||
const firstRecentResult = page.locator(".search-result-row").first();
|
||||
await input.press("ArrowDown");
|
||||
await expect(firstRecentResult).toHaveAttribute("aria-selected", "true");
|
||||
await input.press("ArrowUp");
|
||||
await expect(scopeControl).toHaveAttribute("aria-selected", "true");
|
||||
await input.press("Enter");
|
||||
|
||||
const scopeChip = page.getByTestId("search-channel-scope-chip");
|
||||
await expect(scopeChip).toHaveText(/#general/);
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("w");
|
||||
const relevantHeader = page.getByText("Most relevant", { exact: true });
|
||||
const firstScopedResult = page
|
||||
.locator('[data-search-section="messages"] .search-result-row')
|
||||
.first();
|
||||
await expect(page.getByText("Welcome to general")).toBeVisible();
|
||||
await expect(page.getByText(/Searching messages in/)).toHaveCount(0);
|
||||
await expect(relevantHeader).toBeVisible();
|
||||
await expect(firstScopedResult).toBeVisible();
|
||||
const contentStart = (element: HTMLElement) => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
return (
|
||||
element.getBoundingClientRect().left +
|
||||
Number.parseFloat(styles.paddingLeft)
|
||||
);
|
||||
};
|
||||
const [inputStart, headerStart, resultStart] = await Promise.all([
|
||||
page.getByTestId("search-dialog-input-row").evaluate(contentStart),
|
||||
relevantHeader.evaluate(contentStart),
|
||||
firstScopedResult.evaluate(contentStart),
|
||||
]);
|
||||
expect(Math.abs(inputStart - headerStart)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(inputStart - resultStart)).toBeLessThanOrEqual(1);
|
||||
|
||||
await input.fill("x");
|
||||
await expect(page.getByTestId("search-results")).toContainText(
|
||||
"No messages for x in #general.",
|
||||
);
|
||||
|
||||
await scopeChip.click();
|
||||
await expect(scopeChip).toHaveCount(0);
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("shipped");
|
||||
await expect(page.getByTestId("search-results")).toContainText(
|
||||
"Engineering shipped the desktop build.",
|
||||
);
|
||||
});
|
||||
|
||||
test("global search offers a conversation-specific scope in direct messages", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directMessageId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8";
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-alice-tyler").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`#\\/channels\\/${directMessageId}$`),
|
||||
);
|
||||
|
||||
await focusSidebarSearchWithShortcut(page);
|
||||
|
||||
const scopeControl = page.getByTestId("search-current-channel-control");
|
||||
await expect
|
||||
.poll(() =>
|
||||
scopeControl
|
||||
.getByTestId("search-current-scope-label")
|
||||
.evaluate((element) => element.textContent),
|
||||
)
|
||||
.toBe("Search conversation with alice");
|
||||
await expect(scopeControl).toContainText("Search conversation with alice");
|
||||
await expect(scopeControl).toContainText(
|
||||
"Search messages in this conversation.",
|
||||
);
|
||||
await expect(scopeControl).not.toContainText("channel");
|
||||
await expect(page.getByTestId("search-dialog-input-row")).toHaveCSS(
|
||||
"border-bottom-width",
|
||||
"1px",
|
||||
);
|
||||
await expect(scopeControl.locator("..")).toHaveCSS(
|
||||
"border-bottom-width",
|
||||
"0px",
|
||||
);
|
||||
await expect(scopeControl.locator("..")).toHaveCSS("padding-top", "14px");
|
||||
await expect(scopeControl.locator("..")).toHaveCSS("padding-bottom", "14px");
|
||||
|
||||
await scopeControl.click();
|
||||
|
||||
const scopeChip = page.getByTestId("search-channel-scope-chip");
|
||||
const input = page.getByTestId("search-dialog-input");
|
||||
await expect(scopeChip).toHaveText(/^alice$/);
|
||||
await expect(scopeChip).not.toContainText("#");
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("a");
|
||||
await expect(page.getByTestId("search-results")).toContainText(
|
||||
"No messages for a in alice.",
|
||||
await input.fill("welcome");
|
||||
|
||||
const currentChannelSection = page.locator(
|
||||
'[data-search-section="current-channel-messages"]',
|
||||
);
|
||||
await expect(currentChannelSection).toContainText("In this conversation");
|
||||
await expect(currentChannelSection).toContainText("Welcome to #general");
|
||||
await expect(
|
||||
currentChannelSection.locator(".search-result-row").first(),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
@@ -600,28 +483,27 @@ test("global search offers a conversation-specific scope in direct messages", as
|
||||
window as Window & {
|
||||
__BUZZ_E2E_COMMAND_LOG__?: Array<{
|
||||
command: string;
|
||||
payload: unknown;
|
||||
payload: { channelId?: string; q?: string };
|
||||
}>;
|
||||
}
|
||||
).__BUZZ_E2E_COMMAND_LOG__ ?? [];
|
||||
|
||||
return calls.findLast((entry) => entry.command === "search_messages")
|
||||
?.payload;
|
||||
return calls
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.command === "search_messages" &&
|
||||
entry.payload.q === "welcome",
|
||||
)
|
||||
.map((entry) => entry.payload.channelId ?? null);
|
||||
}),
|
||||
)
|
||||
.toMatchObject({
|
||||
channelId: directMessageId,
|
||||
q: "a",
|
||||
});
|
||||
.toEqual(
|
||||
expect.arrayContaining([null, "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]),
|
||||
);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(page.getByTestId("search-results")).toBeVisible();
|
||||
await expect(page.getByTestId("search-channel-scope-chip")).toHaveText(
|
||||
/^alice$/,
|
||||
);
|
||||
await expect(page.getByTestId("search-current-channel-control")).toHaveCount(
|
||||
0,
|
||||
await input.fill("shipped");
|
||||
await expect(page.getByTestId("search-results")).toContainText(
|
||||
"Engineering shipped the desktop build.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -710,15 +592,14 @@ test("global search exposes a larger scrollable result window", async ({
|
||||
await page.getByTestId("search-dialog-input").fill("deep history message");
|
||||
|
||||
const resultRows = page.locator(
|
||||
'[data-search-section="messages"] .search-result-row',
|
||||
'[data-search-section="current-channel-messages"] .search-result-row',
|
||||
);
|
||||
await expect(resultRows).toHaveCount(40);
|
||||
const resultList = page.getByTestId("search-results-list");
|
||||
await expect(resultList).toBeVisible();
|
||||
const scopeControl = page.getByTestId("search-current-channel-control");
|
||||
await expect(
|
||||
resultList.getByTestId("search-current-channel-control"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("search-current-channel-control")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
const dimensions = await resultList.evaluate((element) => ({
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
@@ -727,12 +608,11 @@ test("global search exposes a larger scrollable result window", async ({
|
||||
await resultList.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
await expect(scopeControl).not.toBeInViewport();
|
||||
await resultList.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
|
||||
for (let index = 0; index < 14; index += 1) {
|
||||
for (let index = 0; index < 13; index += 1) {
|
||||
await page.keyboard.press("ArrowDown");
|
||||
}
|
||||
await expect(resultRows.nth(13)).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
Reference in New Issue
Block a user