"use strict"; const assert = require("node:assert/strict"); const {make_realm} = require("./lib/example_realm.cjs"); const {make_stream} = require("./lib/example_stream.cjs"); const {mock_esm, set_global, zrequire} = require("./lib/namespace.cjs"); const {run_test, noop} = require("./lib/test.cjs"); const {$} = require("./lib/zjquery.cjs"); const bootstrap_typeahead = mock_esm("../src/bootstrap_typeahead"); const people = zrequire("people"); const search = zrequire("search"); const search_pill = zrequire("search_pill"); const search_suggestion = zrequire("search_suggestion"); const {set_current_user, set_realm} = zrequire("state_data"); const stream_data = zrequire("stream_data"); const current_user = {}; set_current_user(current_user); const realm = make_realm(); set_realm(realm); 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[0].before = noop; } set_global("getSelection", () => ({ modify: noop, })); let typeahead_forced_open = false; let search_typeahead; const verona = make_stream({ subscribed: true, color: "blue", name: "Verona", stream_id: 1, }); stream_data.add_sub_for_tests(verona); const zoe = { email: "user7@zulipdev.com", user_id: 3, full_name: "Zoe", }; people.add_active_user(zoe, "server_events"); 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) => html); mock_template("search_description.hbs", true, (_data, html) => html); let expected_pill_display_value = ""; let input_pill_displayed; mock_template("input_pill.hbs", true, (data, html) => { assert.equal(data.display_value, expected_pill_display_value); input_pill_displayed = true; return html; }); override_rewire(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[0].remove = noop; } } let opts; override(bootstrap_typeahead, "Typeahead", (input_element, opts_) => { opts = opts_; assert.equal(input_element.$element[0], $search_query_box[0]); assert.equal(opts.items, 999); assert.equal(opts.helpOnEmptyStrings(), true); assert.equal(opts.matcher("")(), true); search_typeahead = { shown: false, lookup() { typeahead_forced_open = true; }, hide() { this.shown = false; }, }; return search_typeahead; }); search.initialize({ on_narrow_search() {}, }); { { const search_suggestions = ["dm", "dm:"]; /* Test highlighter */ let expected_value = `
\n
Search for dm
\n \n
\n`; assert.equal(opts.item_html("dm")(search_suggestions[0]), expected_value); expected_value = `
\n
\n \n \ndm:\n \n
\n \n
\n
\n
\n
Direct messages with
\n
\n`; assert.equal(opts.item_html("dm")(search_suggestions[1]), expected_value); /* Test sorter */ assert.equal(opts.sorter(search_suggestions.strings), search_suggestions.strings); } { const search_suggestions = ["ver", "stream:Verona"]; /* Test highlighter */ let expected_value = `
\n
Search for ver
\n \n
\n`; assert.equal(opts.item_html("ver")(search_suggestions[0]), expected_value); const search_string = "channel: Verona"; const verona_decorated = `Verona`; const description_html = `Messages in ${verona_decorated}`; expected_value = `
\n
\n \n \n${search_string}\n \n
\n \n
\n
\n
\n
${description_html}
\n
\n`; assert.equal(opts.item_html("ver")(search_suggestions[1]), expected_value); /* Test sorter */ assert.equal(opts.sorter(search_suggestions.strings), search_suggestions.strings); } { const search_suggestions = [ "zo", `sender:${zoe.user_id}`, `dm:${zoe.user_id}`, `dm-including:${zoe.user_id}`, `mentions:${zoe.user_id}`, ]; /* Test highlighter */ const description_html = "Search for zo"; let expected_value = `
\n
${description_html}
\n \n
\n`; assert.equal(opts.item_html("zo")(search_suggestions[0]), expected_value); override(realm, "realm_enable_guest_user_indicator", true); expected_value = `
\n
\n sender:\n \n
\n \n
\n \n Zoe\n
\n \n
\n
\n
\n
\n \n
\n`; assert.equal(opts.item_html("zo")(search_suggestions[1]), expected_value); expected_value = `
\n
\n dm:\n \n
\n \n
\n \n Zoe\n
\n \n
\n
\n
\n
\n \n
\n`; assert.equal(opts.item_html("zo")(search_suggestions[2]), expected_value); expected_value = `
\n
\n dm-including:\n \n
\n \n
\n \n Zoe\n
\n \n
\n
\n
\n
\n \n
\n`; assert.equal(opts.item_html("zo")(search_suggestions[3]), expected_value); expected_value = `
\n
\n mentions:\n \n
\n \n
\n \n Zoe\n
\n \n
\n
\n
\n
\n \n
\n`; assert.equal(opts.item_html("zo")(search_suggestions[4]), expected_value); /* Test sorter */ assert.equal(opts.sorter(search_suggestions.strings), search_suggestions.strings); } { /* Test updater */ const _setup = (terms) => { const pills = search.search_pill_widget._get_pills_for_testing(); for (const pill of pills) { pill.$element[0].remove = noop; } search_pill.set_search_bar_contents( terms, search.search_pill_widget, false, (text) => $search_query_box.text(text), ); }; terms = [ { negated: false, operator: "search", operand: "ver", }, ]; expected_pill_display_value = null; _setup(terms); input_pill_displayed = false; mock_pill_removes(search.search_pill_widget); assert.equal(opts.updater("ver"), "ver"); assert.ok(!input_pill_displayed); const verona_stream_id = verona.stream_id.toString(); terms = [ { negated: false, operator: "channel", operand: verona_stream_id, }, ]; 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_stream_id}`), ""); assert.ok(input_pill_displayed); override_rewire(search, "is_using_input_method", true); _setup(terms); input_pill_displayed = false; mock_pill_removes(search.search_pill_widget); assert.equal(opts.updater(`channel:${verona_stream_id}`), ""); assert.ok(input_pill_displayed); } { /* Test hideAfterSelect */ // An empty search bar means the selected term became a pill, // so the typeahead should hide. $search_query_box.text(""); assert.ok(opts.hideAfterSelect()); // A bare operator still needs an operand, so keep the // typeahead open. $search_query_box.text("channel:"); assert.ok(!opts.hideAfterSelect()); // A complete operator term is done, so hide the typeahead. $search_query_box.text("channel:Verona"); assert.ok(opts.hideAfterSelect()); // A plain search term is also complete, so hide. $search_query_box.text("ver"); assert.ok(opts.hideAfterSelect()); } } $search_query_box.text("test string"); override_rewire(search, "is_using_input_method", false); $searchbox_form.trigger("compositionend"); assert.ok(search.is_using_input_method); const keydown = $searchbox_form.get_on_handler("keydown"); let default_prevented = false; let ev = { type: "keydown", which: 15, preventDefault() { default_prevented = true; }, }; $search_query_box.trigger("blur"); assert.equal(keydown(ev), undefined); assert.ok(!default_prevented); ev.key = "Enter"; assert.equal(keydown(ev), undefined); assert.ok(!default_prevented); ev.key = "Enter"; $search_query_box.trigger("focus"); assert.equal(keydown(ev), undefined); assert.ok(default_prevented); ev = { type: "keyup", }; const _setup = (terms) => { const pills = search.search_pill_widget._get_pills_for_testing(); for (const pill of pills) { pill.$element[0].remove = noop; } search_pill.set_search_bar_contents(terms, search.search_pill_widget, false, (text) => $search_query_box.text(text), ); }; terms = [ { negated: false, operator: "search", operand: "", }, ]; _setup(terms); ev.key = "a"; /* istanbul ignore next */ $search_query_box.trigger("blur"); $searchbox_form.trigger(ev); let search_exited = false; override_rewire(search, "exit_search", () => { search_exited = true; }); ev.key = "Enter"; $search_query_box.trigger("blur"); $searchbox_form.trigger(ev); assert.ok(!search_exited); ev.key = "Enter"; $search_query_box.trigger("focus"); $searchbox_form.trigger(ev); assert.ok(search_exited); terms = [ { negated: false, operator: "search", operand: "ver", }, ]; expected_pill_display_value = "ver"; _setup(terms); ev.key = "Enter"; override_rewire(search, "is_using_input_method", true); $searchbox_form.trigger(ev); // No change on first Enter keyup event assert.ok($search_query_box.is(":focus")); $searchbox_form.trigger(ev); assert.ok(!$search_query_box.is(":focus")); }); 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; }); $.set_results(".navbar-search.expanded", []); $("#search_query").text(""); search.initiate_search(); assert.ok(typeahead_forced_open); assert.ok(search_bar_opened); assert.equal($("#search_query").text(), ""); }); run_test("close_search", () => { search_typeahead.shown = true; $(".navbar-search").addClass("expanded"); search.close_search(); assert.equal(search_typeahead.shown, false); assert.ok(!$(".navbar-search").hasClass("expanded")); }); run_test("create_item_from_search_string with invalid string", () => { search_pill.create_item_from_search_string("is:invalid"); const pills = search.search_pill_widget._get_pills_for_testing(); assert.equal(pills.length, 0); }); run_test("generate_pills_html with unknown channel", ({mock_template, override}) => { mock_template("search_list_item.hbs", true, (_data, html) => html); override(realm, "realm_empty_topic_display_name", "general chat"); // A known channel followed by a topic is rendered as a combined // `#channel > topic` pill. let html = search_pill.generate_pills_html(`channel:${verona.stream_id} topic:lunch`, ""); assert.ok(html.includes("decorated-channel-name")); // The channel can be unknown to this client, e.g. a channel // deleted while a pill referenced it. We render separate pills, // with the channel term rendered as invalid. const unknown_stream_id_string = "999"; assert.equal(stream_data.get_sub_by_id_string(unknown_stream_id_string), undefined); html = search_pill.generate_pills_html(`channel:${unknown_stream_id_string} topic:lunch`, ""); assert.ok(!html.includes("decorated-channel-name")); assert.ok(html.includes(`channel:${unknown_stream_id_string}`)); assert.ok(html.includes("topic: lunch")); // The same, for an empty string topic. html = search_pill.generate_pills_html(`channel:${unknown_stream_id_string} topic:`, ""); assert.ok(!html.includes("decorated-channel-name")); assert.ok(html.includes(`channel:${unknown_stream_id_string}`)); assert.ok(html.includes("empty-topic-display")); }); run_test("generate_pills_html with an empty topic operand", ({mock_template, override}) => { mock_template("search_list_item.hbs", true, (_data, html) => html); override(realm, "realm_empty_topic_display_name", "general chat"); const empty_topic_pill_value = `topic: translated: general chat`; // With nothing typed into the search input, the `topic:` term comes // from an existing pill, where an empty operand means "general chat". let html = search_pill.generate_pills_html("topic:", ""); assert.ok(html.includes(empty_topic_pill_value)); // When another term is being typed after `topic:` (from an existing // pill), the existing `topic:` term still represents an empty operand, // so "general chat" is displayed for it. html = search_pill.generate_pills_html("topic: zo", "zo"); assert.ok(html.includes(empty_topic_pill_value)); // Having typed `topic:` out in full, the user is picking an operand, // so "general chat" is a useful completion. Trailing whitespace // doesn't change that. html = search_pill.generate_pills_html("topic:", "topic:"); assert.ok(html.includes(empty_topic_pill_value)); html = search_pill.generate_pills_html("topic:", "topic: "); assert.ok(html.includes(empty_topic_pill_value)); // But when we're suggesting `topic` as an operator to add, for a // partially typed operator, the user hasn't asked for an operand // yet, so we suggest the bare operator. html = search_pill.generate_pills_html("topic:", "to"); assert.ok(html.includes("topic:")); assert.ok(!html.includes("empty-topic-display")); html = search_pill.generate_pills_html("-topic:", "-to"); assert.ok(html.includes("-topic:")); assert.ok(!html.includes("empty-topic-display")); }); run_test("generate_pills_html for general chat suggestions", ({mock_template, override}) => { mock_template("search_list_item.hbs", true, (_data, html) => html); override(realm, "realm_empty_topic_display_name", "general chat"); const channel_topic_suggestion = `channel:${verona.stream_id} topic:`; function assert_suggests_general_chat(suggestion, text_query) { const html = search_pill.generate_pills_html(suggestion, text_query); assert.ok(html.includes("empty-topic-display")); assert.ok(html.includes("translated: general chat")); } function assert_suggests_topic_operator(suggestion, text_query) { const html = search_pill.generate_pills_html(suggestion, text_query); assert.ok(!html.includes("empty-topic-display")); assert.ok(!html.includes("translated: general chat")); assert.ok(html.includes("topic:")); } // An empty topic operand is rendered as general chat when it's an // already formed pill, a bare `topic:` operator, a topic of a // channel the user has typed, or matches the text the user is // typing for the topic. assert_suggests_general_chat(channel_topic_suggestion, ""); assert_suggests_general_chat(channel_topic_suggestion, "topic:"); assert_suggests_general_chat(channel_topic_suggestion, "-topic:"); assert_suggests_general_chat(channel_topic_suggestion, `channel:${verona.stream_id}`); assert_suggests_general_chat(channel_topic_suggestion, "topic:gen"); assert_suggests_general_chat(channel_topic_suggestion, "topic:general chat"); assert_suggests_general_chat(channel_topic_suggestion, "general"); assert_suggests_general_chat(channel_topic_suggestion, "chat"); // But when the user is still typing the `topic` operator itself, // we're suggesting the operator, not the general chat topic. assert_suggests_topic_operator(channel_topic_suggestion, "to"); assert_suggests_topic_operator(channel_topic_suggestion, "Topic"); assert_suggests_topic_operator(`channel:${verona.stream_id} -topic:`, "-to"); // The operator suggestion only applies to the last term; an // earlier empty topic term is always general chat. assert_suggests_general_chat(`channel:${verona.stream_id} topic: has:link`, "to"); }); run_test("set_search_bar_contents with duplicate pills", () => { const duplicate_attachment_terms = [ { negated: false, operator: "has", operand: "attachment", }, { negated: false, operator: "has", operand: "attachment", }, ]; search_pill.set_search_bar_contents( duplicate_attachment_terms, search.search_pill_widget, false, noop, ); const pills = search.search_pill_widget._get_pills_for_testing(); assert.equal(pills.length, 1); assert.deepEqual(pills[0].item, { type: "generic_operator", operator: "has", operand: "attachment", negated: false, }); });