search: Use pills in the search bar.

This initial commit is fairly bare-bones, and does not have the full
contemplated functionality.
This commit is contained in:
evykassirer 2024-01-25 13:11:22 -08:00 committed by Tim Abbott
parent 0ab4f84561
commit 38e58ea3d6
10 changed files with 296 additions and 136 deletions

View File

@ -198,6 +198,7 @@ EXEMPT_FILES = make_set(
"web/src/scroll_bar.ts",
"web/src/scroll_util.ts",
"web/src/search.ts",
"web/src/search_pill.ts",
"web/src/sent_messages.ts",
"web/src/sentry.ts",
"web/src/server_events.js",

View File

@ -47,6 +47,7 @@ async function test_restore_stream_message_draft_by_opening_compose_box(page: Pa
await page.click(".search_icon");
await page.waitForSelector("#search_query", {visible: true});
await common.clear_and_type(page, "#search_query", "stream:Denmark topic:tests");
await page.keyboard.press("Enter");
// Wait for narrow to complete.
const wait_for_change = true;
await common.get_current_msg_list_id(page, wait_for_change);

View File

@ -179,7 +179,11 @@ async function search_and_check(
): Promise<void> {
await page.click(".search_icon");
await page.waitForSelector(".navbar-search.expanded", {visible: true});
// Close the "in: home" pill
await page.click(".navbar-search .pill-close-button");
await common.select_item_via_typeahead(page, "#search_query", search_str, item_to_select);
// Enter to trigger search
await page.keyboard.press("Enter");
await check(page);
assert.strictEqual(await page.title(), expected_narrow_title);
await un_narrow(page);
@ -189,7 +193,11 @@ async function search_and_check(
async function search_silent_user(page: Page, str: string, item: string): Promise<void> {
await page.click(".search_icon");
await page.waitForSelector(".navbar-search.expanded", {visible: true});
// Close the "in: home" pill
await page.click(".navbar-search .pill-close-button");
await common.select_item_via_typeahead(page, "#search_query", str, item);
// Enter to trigger search
await page.keyboard.press("Enter");
await page.waitForSelector(".empty_feed_notice", {visible: true});
const expect_message = "You haven't received any messages sent by Email Gateway yet.";
assert.strictEqual(
@ -224,7 +232,11 @@ async function expect_non_existing_users(page: Page): Promise<void> {
async function search_non_existing_user(page: Page, str: string, item: string): Promise<void> {
await page.click(".search_icon");
await page.waitForSelector(".navbar-search.expanded", {visible: true});
// Close the "in: home" pill
await page.click(".navbar-search .pill-close-button");
await common.select_item_via_typeahead(page, "#search_query", str, item);
// Enter to trigger search
await page.keyboard.press("Enter");
await expect_non_existing_user(page);
await un_narrow(page);
await expect_home(page);

View File

@ -157,7 +157,6 @@ function build_message_view_header(filter: Filter | undefined): void {
// message_view_header on a template where it's never used
if (filter && !filter.is_common_narrow()) {
search.open_search_bar_and_close_narrow_description();
search.set_search_bar_text(narrow_state.search_string());
} else {
const context = get_message_view_header_context(filter);
append_and_display_title_area(context);

View File

@ -9,18 +9,18 @@ import {Filter} from "./filter";
import * as keydown_util from "./keydown_util";
import * as narrow_state from "./narrow_state";
import * as popovers from "./popovers";
import * as search_pill from "./search_pill";
import type {SearchPillWidget} from "./search_pill";
import * as search_suggestion from "./search_suggestion";
import type {NarrowTerm} from "./state_data";
// Exported for unit testing
export let is_using_input_method = false;
export let search_pill_widget: SearchPillWidget | null = null;
let search_input_has_changed = false;
let search_typeahead: Typeahead<string>;
export function set_search_bar_text(text: string): void {
$("#search_query").text(text);
}
function get_search_bar_text(): string {
return $("#search_query").text();
}
@ -32,15 +32,7 @@ type NarrowSearchOptions = {
type OnNarrowSearch = (terms: NarrowTerm[], options: NarrowSearchOptions) => void;
function narrow_or_search_for_term(
search_string: string,
{on_narrow_search}: {on_narrow_search: OnNarrowSearch},
): string {
if (search_string === "") {
exit_search({keep_search_narrow_open: true});
return "";
}
const $search_query_box = $("#search_query");
function narrow_or_search_for_term({on_narrow_search}: {on_narrow_search: OnNarrowSearch}): string {
if (is_using_input_method) {
// Neither narrow nor search when using input tools as
// `updater` is also triggered when 'enter' is triggered
@ -48,7 +40,13 @@ function narrow_or_search_for_term(
return get_search_bar_text();
}
const terms = Filter.parse(search_string);
assert(search_pill_widget !== null);
const search_query = search_pill.get_current_search_string_for_widget(search_pill_widget);
if (search_query === "") {
exit_search({keep_search_narrow_open: true});
return "";
}
const terms = Filter.parse(search_query);
on_narrow_search(terms, {trigger: "search"});
// It's sort of annoying that this is not in a position to
@ -57,13 +55,19 @@ function narrow_or_search_for_term(
// Narrowing will have already put some terms in the search box,
// so leave the current text in.
$search_query_box.trigger("blur");
$("#search_query").trigger("blur");
return get_search_bar_text();
}
export function initialize({on_narrow_search}: {on_narrow_search: OnNarrowSearch}): void {
const $search_query_box = $<HTMLInputElement>("#search_query");
const $searchbox_form = $("#searchbox_form");
const $pill_container = $("#searchbox-input-container.pill-container");
search_pill_widget = search_pill.create_pills($pill_container);
search_pill_widget.onPillRemove(() => {
search_input_has_changed = true;
});
// Data storage for the typeahead.
// This maps a search string to an object with a "description_html" field.
@ -78,7 +82,13 @@ export function initialize({on_narrow_search}: {on_narrow_search: OnNarrowSearch
};
search_typeahead = new Typeahead(bootstrap_typeahead_input, {
source(query: string): string[] {
const suggestions = search_suggestion.get_suggestions(query);
if (query !== "") {
search_input_has_changed = true;
}
assert(search_pill_widget !== null);
const query_from_pills =
search_pill.get_current_search_string_for_widget(search_pill_widget);
const suggestions = search_suggestion.get_suggestions(`${query_from_pills} ${query}`);
// Update our global search_map hash
search_map = suggestions.lookup_table;
return suggestions.strings;
@ -86,21 +96,44 @@ export function initialize({on_narrow_search}: {on_narrow_search: OnNarrowSearch
non_tippy_parent_element: "#searchbox_form",
items: search_suggestion.max_num_of_search_results,
helpOnEmptyStrings: true,
naturalSearch: true,
stopAdvance: true,
requireHighlight: false,
highlighter_html(item: string): string {
const obj = search_map.get(item);
return render_search_list_item(obj);
},
// When the user starts typing new search operands,
// we want to highlight the first typeahead row by default
// so that pressing Enter creates the default pill.
// But when user isn't in the middle of typing a new pill,
// pressing Enter should let them search for what's currently
// in the search bar, so we remove the highlight (so that
// Enter won't have anything to select).
shouldHighlightFirstResult(): boolean {
return get_search_bar_text() !== "";
},
matcher(): boolean {
return true;
},
updater(search_string: string): string {
return narrow_or_search_for_term(search_string, {on_narrow_search});
if (search_string) {
search_input_has_changed = true;
// Reset the search box and add the pills based on the selected
// search suggestion.
assert(search_pill_widget !== null);
const search_terms = Filter.parse(search_string);
search_pill.set_search_bar_contents(search_terms, search_pill_widget);
$search_query_box.trigger("focus");
}
return get_search_bar_text();
},
sorter(items: string[]): string[] {
return items;
},
advanceKeyCodes: [8],
// Turns off `stopPropagation` in the typeahead code for
// backspace, arrow left, arrow right, so that we can
// manage those events for input pills.
advanceKeyCodes: [8, 37, 39],
// Use our custom typeahead `on_escape` hook to exit
// the search bar as soon as the user hits Esc.
@ -113,17 +146,16 @@ export function initialize({on_narrow_search}: {on_narrow_search: OnNarrowSearch
open_search_bar_and_close_narrow_description();
}
},
// This is here so that we can close the search bar
// when a user opens it and immediately changes their
// mind and clicks away.
closeInputFieldOnHide(): void {
// Don't close the search bar if the user has changed
// the text from the default, they might accidentally
// click away and not want to lose it.
if (get_initial_search_string() !== get_search_bar_text()) {
return;
}
if (!search_input_has_changed) {
const filter = narrow_state.filter();
if (!filter || filter.is_common_narrow()) {
close_search_bar_and_open_narrow_description();
}
}
},
});
@ -151,17 +183,10 @@ export function initialize({on_narrow_search}: {on_narrow_search: OnNarrowSearch
return;
}
if (keydown_util.is_enter_event(e) && $search_query_box.is(":focus")) {
// We just pressed Enter and the box had focus, which
// means we didn't use the typeahead at all. In that
// case, we should act as though we're searching by
// terms. (The reason the other actions don't call
// this codepath is that they first all blur the box to
// indicate that they've done what they need to do)
// Pill is already added during keydown event of input pills.
narrow_or_search_for_term(get_search_bar_text(), {on_narrow_search});
$search_query_box.trigger("blur");
if (e.key === "Escape" && $search_query_box.is(":focus")) {
exit_search({keep_search_narrow_open: false});
} else if (keydown_util.is_enter_event(e) && $search_query_box.is(":focus")) {
narrow_or_search_for_term({on_narrow_search});
}
});
@ -214,30 +239,21 @@ export function initialize({on_narrow_search}: {on_narrow_search: OnNarrowSearch
export function initiate_search(): void {
open_search_bar_and_close_narrow_description();
$("#search_query").trigger("focus");
// Open the typeahead after opening the search bar, so that we don't
// get a weird visual jump where the typeahead results are narrow
// before the search bar expands and then wider it expands.
search_typeahead.lookup(false);
$("#search_query").trigger("select");
}
// This is what the default searchbox text would be for this narrow,
// NOT what might be currently displayed there. We can use this both
// to set the initial text and to see if the user has changed it.
function get_initial_search_string(): string {
let search_string = narrow_state.search_string();
if (search_string !== "" && !narrow_state.filter()?.is_keyword_search()) {
// saves the user a keystroke for quick searches
search_string = search_string + " ";
}
return search_string;
}
// we rely entirely on this function to ensure
// the searchbar has the right text.
function reset_searchbox_text(): void {
set_search_bar_text(get_initial_search_string());
// the searchbar has the right text/pills.
function reset_searchbox(): void {
assert(search_pill_widget !== null);
search_pill_widget.clear();
search_input_has_changed = false;
search_pill.set_search_bar_contents(narrow_state.search_terms(), search_pill_widget);
}
function exit_search(opts: {keep_search_narrow_open: boolean}): void {
@ -261,7 +277,7 @@ export function open_search_bar_and_close_narrow_description(): void {
// otherwise fill the input field with the text terms for
// the current narrow.
if (get_search_bar_text() === "") {
reset_searchbox_text();
reset_searchbox();
}
$(".navbar-search").addClass("expanded");
$("#message_view_header").addClass("hidden");
@ -274,7 +290,15 @@ export function close_search_bar_and_open_narrow_description(): void {
// in width as the search bar closes, which doesn't look great.
$("#searchbox_form .dropdown-menu").hide();
set_search_bar_text("");
if (search_pill_widget !== null) {
search_pill_widget.clear();
}
$(".navbar-search").removeClass("expanded");
$("#message_view_header").removeClass("hidden");
if ($("#search_query").is(":focus")) {
$("#search_query").trigger("blur");
$(".app").trigger("focus");
}
}

53
web/src/search_pill.ts Normal file
View File

@ -0,0 +1,53 @@
import {Filter} from "./filter";
import * as input_pill from "./input_pill";
import type {InputPillContainer} from "./input_pill";
import type {NarrowTerm} from "./state_data";
type SearchPill = {
display_value: string;
type: string;
description_html: string;
};
export type SearchPillWidget = InputPillContainer<SearchPill>;
export function create_item_from_search_string(search_string: string): SearchPill {
const search_terms = Filter.parse(search_string);
const description_html = Filter.search_description_as_html(search_terms);
return {
display_value: search_string,
type: "search",
description_html,
};
}
export function get_search_string_from_item(item: SearchPill): string {
return item.display_value;
}
export function create_pills($pill_container: JQuery): SearchPillWidget {
const pills = input_pill.create({
$container: $pill_container,
create_item_from_text: create_item_from_search_string,
get_text_from_item: get_search_string_from_item,
split_text_on_comma: false,
});
return pills;
}
export function set_search_bar_contents(
search_terms: NarrowTerm[],
pill_widget: SearchPillWidget,
): void {
pill_widget.clear();
for (const term of search_terms) {
const input = Filter.unparse([term]);
pill_widget.appendValue(input);
}
pill_widget.clear_text();
}
export function get_current_search_string_for_widget(pill_widget: SearchPillWidget): string {
const items = pill_widget.items();
const search_strings = items.map((item) => item.display_value);
return search_strings.join(" ");
}

View File

@ -58,7 +58,7 @@
}
.zulip-icon-close {
font-size: 13px;
font-size: 10px;
}
.navbar-search:not(.expanded) {
@ -159,6 +159,13 @@
.search-input {
/* Avoid massive inheritance chain on font-size. */
font-size: var(--base-font-size-px);
/* override height-input-pill from default input pills to account for pill border */
line-height: calc(
var(--height-input-pill) + var(--vertical-spacing-input-pill)
);
min-height: calc(
var(--height-input-pill) + var(--vertical-spacing-input-pill)
);
/* override bootstrap padding for input[type="text"] */
padding: 0;
border: none;
@ -189,9 +196,36 @@
#searchbox-input-container {
display: flex;
align-items: center;
/* override .input-append style from app_components.js */
/* The next two styles override .input-append style from app_components.js */
letter-spacing: normal;
font-size: 100%;
height: var(--search-box-height);
/* Override styles for .pill-container that aren't relevant for search. */
flex-wrap: nowrap;
padding: 0;
border: none;
.user-pill-container {
padding: 2px;
height: 22px;
min-width: fit-content;
> .pill-label {
min-width: fit-content;
white-space: nowrap;
width: fit-content;
}
.pill {
height: 22px;
margin: 2px;
}
.pill-image {
width: 22px;
height: 22px;
}
}
}
@media (width >= $md_min) {

View File

@ -22,7 +22,7 @@
</div>
<div id="searchbox">
<form id="searchbox_form" class="navbar-search">
<div id="searchbox-input-container" class="input-append">
<div id="searchbox-input-container" class="input-append pill-container">
<i class="search_icon zulip-icon zulip-icon-search"></i>
<div class="search-input input input-block-level home-page-input" id="search_query" type="text" data-placeholder-text="{{t 'Search' }}"
autocomplete="off" contenteditable="true"></div>

View File

@ -23,6 +23,7 @@ function FakeElement(selector, opts) {
const event_store = make_event_store(selector);
const $self = {
[0]: {textContent: text},
*[Symbol.iterator]() {
// eslint-disable-next-line unicorn/no-for-loop
for (let i = 0; i < $self.length; i += 1) {

View File

@ -7,22 +7,24 @@ const {run_test, noop} = require("./lib/test");
const $ = require("./lib/zjquery");
const bootstrap_typeahead = mock_esm("../src/bootstrap_typeahead");
const narrow_state = mock_esm("../src/narrow_state");
const search_suggestion = mock_esm("../src/search_suggestion");
const Filter = {};
mock_esm("../src/filter", {
Filter,
});
const search = zrequire("search");
const search_pill = zrequire("search_pill");
function stub_pills() {
const $pill_container = $("#searchbox-input-container.pill-container");
const $pill_input = $.create("pill_input");
$pill_container.set_find_results(".input", $pill_input);
$pill_input.before = noop;
}
let typeahead_forced_open = false;
run_test("initialize", ({override, override_rewire, mock_template}) => {
const $search_query_box = $("#search_query");
const $searchbox_form = $("#searchbox_form");
stub_pills();
mock_template("search_list_item.hbs", true, (data, html) => {
assert.equal(typeof data.description_html, "string");
@ -37,13 +39,32 @@ run_test("initialize", ({override, override_rewire, mock_template}) => {
return html;
});
let expected_suggestion_parts = [];
mock_template("search_description.hbs", false, (data, html) => {
assert.deepStrictEqual(data.parts, expected_suggestion_parts);
return html;
});
let expected_pill_display_value = "";
let input_pill_displayed = false;
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(data.display_value, expected_pill_display_value);
input_pill_displayed = true;
return html;
});
search_suggestion.max_num_of_search_results = 999;
let terms;
function mock_pill_removes(widget) {
const pills = widget._get_pills_for_testing();
for (const pill of pills) {
pill.$element.remove = noop;
}
}
override(bootstrap_typeahead, "Typeahead", (input_element, opts) => {
assert.equal(input_element.$element, $search_query_box);
assert.equal(opts.items, 999);
assert.equal(opts.naturalSearch, true);
assert.equal(opts.helpOnEmptyStrings, true);
assert.equal(opts.matcher(), true);
@ -72,7 +93,7 @@ run_test("initialize", ({override, override_rewire, mock_template}) => {
search_suggestion.get_suggestions = () => search_suggestions;
const expected_source_value = search_suggestions.strings;
const source = opts.source("ver");
assert.equal(source, expected_source_value);
assert.deepStrictEqual(source, expected_source_value);
/* Test highlighter */
let expected_value = `<div class="search_list_item">\n <span>Search for ver</span>\n</div>\n`;
@ -160,7 +181,7 @@ run_test("initialize", ({override, override_rewire, mock_template}) => {
search_suggestion.get_suggestions = () => search_suggestions;
const expected_source_value = search_suggestions.strings;
const source = opts.source("zo");
assert.equal(source, expected_source_value);
assert.deepStrictEqual(source, expected_source_value);
/* Test highlighter */
let expected_value = `<div class="search_list_item">\n <span>Search for zo</span>\n</div>\n`;
@ -180,18 +201,13 @@ run_test("initialize", ({override, override_rewire, mock_template}) => {
}
{
let is_blurred;
$search_query_box.on("blur", () => {
is_blurred = true;
});
/* Test updater */
const _setup = (search_box_val) => {
is_blurred = false;
$search_query_box.text(search_box_val);
Filter.parse = (search_string) => {
assert.equal(search_string, search_box_val);
return terms;
};
const _setup = (terms) => {
const pills = search.search_pill_widget._get_pills_for_testing();
for (const pill of pills) {
pill.$element.remove = noop;
}
search_pill.set_search_bar_contents(terms, search.search_pill_widget);
};
terms = [
@ -201,27 +217,47 @@ run_test("initialize", ({override, override_rewire, mock_template}) => {
operand: "ver",
},
];
_setup("ver");
assert.equal(opts.updater("ver"), "ver");
assert.ok(is_blurred);
expected_suggestion_parts = [
{
operand: "ver",
prefix_for_operator: "search for",
type: "prefix_for_operator",
},
];
expected_pill_display_value = "ver";
_setup(terms);
input_pill_displayed = false;
mock_pill_removes(search.search_pill_widget);
assert.equal(opts.updater("ver"), "");
assert.ok(input_pill_displayed);
terms = [
{
negated: false,
operator: "stream",
operator: "channel",
operand: "Verona",
},
];
_setup("stream:Verona");
assert.equal(opts.updater("stream:Verona"), "stream:Verona");
assert.ok(is_blurred);
expected_suggestion_parts = [
{
type: "prefix_for_operator",
prefix_for_operator: "channel",
operand: "Verona",
},
];
expected_pill_display_value = "channel:Verona";
_setup(terms);
input_pill_displayed = false;
mock_pill_removes(search.search_pill_widget);
assert.equal(opts.updater("channel:Verona"), "");
assert.ok(input_pill_displayed);
search.__Rewire__("is_using_input_method", true);
_setup("stream:Verona");
assert.equal(opts.updater("stream:Verona"), "stream:Verona");
assert.ok(!is_blurred);
$search_query_box.off("blur");
_setup(terms);
input_pill_displayed = false;
mock_pill_removes(search.search_pill_widget);
assert.equal(opts.updater("channel:Verona"), "");
assert.ok(input_pill_displayed);
}
return {
lookup() {
@ -231,14 +267,10 @@ run_test("initialize", ({override, override_rewire, mock_template}) => {
});
search.initialize({
on_narrow_search(raw_terms, options) {
assert.deepEqual(raw_terms, terms);
assert.deepEqual(options, {trigger: "search"});
},
on_narrow_search() {},
});
$search_query_box.text("test string");
narrow_state.search_string = () => "ver";
search.__Rewire__("is_using_input_method", false);
$searchbox_form.trigger("compositionend");
@ -269,19 +301,13 @@ run_test("initialize", ({override, override_rewire, mock_template}) => {
ev = {
type: "keyup",
};
let is_blurred;
$search_query_box.off("blur");
$search_query_box.on("blur", () => {
is_blurred = true;
});
const _setup = (search_box_val) => {
is_blurred = false;
$search_query_box.text(search_box_val);
Filter.parse = (search_string) => {
assert.equal(search_string, search_box_val);
return terms;
};
const _setup = (terms) => {
const pills = search.search_pill_widget._get_pills_for_testing();
for (const pill of pills) {
pill.$element.remove = noop;
}
search_pill.set_search_bar_contents(terms, search.search_pill_widget);
};
terms = [
@ -291,57 +317,66 @@ run_test("initialize", ({override, override_rewire, mock_template}) => {
operand: "",
},
];
_setup("");
_setup(terms);
ev.key = "a";
/* istanbul ignore next */
$search_query_box.is = () => false;
$searchbox_form.trigger(ev);
assert.ok(!is_blurred);
let search_exited = false;
override_rewire(search, "exit_search", () => {
search_exited = true;
});
ev.key = "Enter";
$search_query_box.is = () => false;
$searchbox_form.trigger(ev);
assert.ok(!search_exited);
assert.ok(!is_blurred);
override_rewire(search, "exit_search", noop);
ev.key = "Enter";
$search_query_box.is = () => true;
$searchbox_form.trigger(ev);
assert.ok(is_blurred);
assert.ok(search_exited);
_setup("ver");
let is_blurred = false;
$search_query_box.on("blur", () => {
is_blurred = true;
});
terms = [
{
negated: false,
operator: "search",
operand: "ver",
},
];
expected_suggestion_parts = [
{
operand: "ver",
prefix_for_operator: "search for",
type: "prefix_for_operator",
},
];
expected_pill_display_value = "ver";
_setup(terms);
ev.key = "Enter";
search.__Rewire__("is_using_input_method", true);
$searchbox_form.trigger(ev);
// No change on Enter keyup event when using input tool
// No change on first Enter keyup event
assert.ok(!is_blurred);
_setup("ver");
ev.key = "Enter";
$search_query_box.is = () => true;
$searchbox_form.trigger(ev);
assert.ok(is_blurred);
});
run_test("initiate_search", () => {
// open typeahead and select text when navbar is open
// this implicitly expects the code to used the chained
// function calls, which is something to keep in mind if
// this test ever fails unexpectedly.
narrow_state.filter = () => ({is_keyword_search: () => false});
let is_searchbox_text_selected = false;
$("#search_query").on("select", () => {
is_searchbox_text_selected = true;
run_test("initiate_search", ({override_rewire}) => {
let search_bar_opened = false;
override_rewire(search, "open_search_bar_and_close_narrow_description", () => {
search_bar_opened = true;
});
$(".navbar-search.expanded").length = 0;
$("#search_query").text("");
search.initiate_search();
assert.ok(typeahead_forced_open);
assert.ok(is_searchbox_text_selected);
// test that we append space for user convenience
assert.equal($("#search_query").text(), "ver ");
assert.ok(search_bar_opened);
assert.equal($("#search_query").text(), "");
});