From 92bb728fe52b71dbfbc181c03cad8fb0d84bed94 Mon Sep 17 00:00:00 2001 From: Isaac Janzen <50783505+janzenisaac@users.noreply.github.com> Date: Wed, 11 Jan 2023 13:02:22 -0600 Subject: [PATCH] DEV: Add search suggestions for tag-intersections (#19777) Added `tagIntersection` search context for handling search suggestions on tag intersection and tag+category routes. # Tag & Category Route Search Suggestions eg. /tags/c/general/5/updates ### Before Screenshot 2023-01-06 at 2 58 50 PM ### After Screenshot 2023-01-06 at 3 00 35 PM # Tag Intersection Route Search Suggestions eg. /tags/intersection/updates/foo ### Before Screenshot 2023-01-06 at 3 02 23 PM ### After Screenshot 2023-01-09 at 2 02 09 PM I defaulted to using `+` as a separator for tag intersections. The reasoning behind this is that we don't make the tag intersection routes easily accessible, so if you are going out of your way to view multiple tags, you are most likely going to be searching by **both** of those tags as well. # General Search Introducing flex wrap removes whitespace causing a [test](https://github.com/discourse/discourse/pull/19777/files#diff-5d3d13fabc1a511635eb7471ffe74f4d455d77f6984543c2be6ad136dfaa6d3aR813) to fail, but to remedy this I added spacing to the `.search-item-prefix` and `.search-item-slug` which achieves the same thing. ### After Screenshot 2023-01-09 at 2 04 54 PM --- .../discourse/app/routes/tag-show.js | 16 ++- .../app/widgets/search-menu-results.js | 84 +++++++++++- .../discourse/tests/acceptance/search-test.js | 122 +++++++++++++++++- .../stylesheets/common/base/search-menu.scss | 16 ++- 4 files changed, 226 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/discourse/app/routes/tag-show.js b/app/assets/javascripts/discourse/app/routes/tag-show.js index 9c73f3b1d8d..2482f9159de 100644 --- a/app/assets/javascripts/discourse/app/routes/tag-show.js +++ b/app/assets/javascripts/discourse/app/routes/tag-show.js @@ -136,7 +136,21 @@ export default DiscourseRoute.extend(FilterModeMixin, { noSubcategories, loading: false, }); - this.searchService.set("searchContext", model.tag.searchContext); + + if (model.category || model.additionalTags) { + const tagIntersectionSearchContext = { + type: "tagIntersection", + tagId: model.tag.id, + tag: model.tag, + additionalTags: model.additionalTags || null, + categoryId: model.category?.id || null, + category: model.category || null, + }; + + this.searchService.set("searchContext", tagIntersectionSearchContext); + } else { + this.searchService.set("searchContext", model.tag.searchContext); + } }, titleToken() { diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js index bc88e254e5c..6b4464313d6 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js @@ -415,13 +415,42 @@ createWidget("search-menu-assistant", { const content = []; const { suggestionKeyword, term } = attrs; - let prefix = term?.split(suggestionKeyword)[0].trim() || ""; - if (prefix.length) { - prefix = `${prefix} `; + let prefix; + if (suggestionKeyword !== "+") { + prefix = term?.split(suggestionKeyword)[0].trim() || ""; + + if (prefix.length) { + prefix = `${prefix} `; + } } switch (suggestionKeyword) { + case "+": + attrs.results.forEach((item) => { + if (item.additionalTags) { + prefix = term?.split(" ").slice(0, -1).join(" ").trim() || ""; + } else { + prefix = term?.split("#")[0].trim() || ""; + } + + if (prefix.length) { + prefix = `${prefix} `; + } + + content.push( + this.attach("search-menu-assistant-item", { + prefix, + tag: item.tagName, + additionalTags: item.additionalTags, + category: item.category, + slug: term, + withInLabel: attrs.withInLabel, + isIntersection: true, + }) + ); + }); + break; case "#": attrs.results.forEach((item) => { if (item.model) { @@ -572,6 +601,36 @@ createWidget("search-menu-initial-options", { }) ); break; + case "tagIntersection": + let tagTerm; + if (ctx.additionalTags) { + const tags = [ctx.tagId, ...ctx.additionalTags]; + tagTerm = `${term} tags:${tags.join("+")}`; + } else { + tagTerm = `${term} #${ctx.tagId}`; + } + let suggestionOptions = { + tagName: ctx.tagId, + additionalTags: ctx.additionalTags, + }; + if (ctx.category) { + const categorySlug = ctx.category.parentCategory + ? `#${ctx.category.parentCategory.slug}:${ctx.category.slug}` + : `#${ctx.category.slug}`; + suggestionOptions.categoryName = categorySlug; + suggestionOptions.category = ctx.category; + tagTerm = tagTerm + ` ${categorySlug}`; + } + + content.push( + this.attach("search-menu-assistant", { + term: tagTerm, + suggestionKeyword: "+", + results: [suggestionOptions], + withInLabel: true, + }) + ); + break; case "user": content.push( this.attach("search-menu-assistant-item", { @@ -677,11 +736,22 @@ createWidget("search-menu-assistant-item", { link: false, }) ); - } else if (attrs.tag) { - attributes.href = getURL(`/tag/${attrs.tag}`); - content.push(iconNode("tag")); - content.push(h("span.search-item-tag", attrs.tag)); + // category and tag combination + if (attrs.tag && attrs.isIntersection) { + attributes.href = getURL(`/tag/${attrs.tag}`); + content.push(iconNode("tag")); + content.push(h("span.search-item-tag", attrs.tag)); + } + } else if (attrs.tag) { + if (attrs.isIntersection && attrs.additionalTags?.length) { + const tags = [attrs.tag, ...attrs.additionalTags]; + content.push(h("span.search-item-tag", `tags:${tags.join("+")}`)); + } else { + attributes.href = getURL(`/tag/${attrs.tag}`); + content.push(iconNode("tag")); + content.push(h("span.search-item-tag", attrs.tag)); + } } else if (attrs.user) { const userResult = [ avatarImg("small", { diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-test.js index f0b21db01e7..49a7cff76f2 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/search-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/search-test.js @@ -757,6 +757,47 @@ acceptance("Search - assistant", function (needs) { return helper.response(searchFixtures["search/query"]); }); + server.get("/tag/dev/notifications", () => { + return helper.response({ + tag_notification: { id: "dev", notification_level: 2 }, + }); + }); + + server.get("/tags/c/bug/1/dev/l/latest.json", () => { + return helper.response({ + users: [], + primary_groups: [], + topic_list: { + can_create_topic: true, + draft: null, + draft_key: "new_topic", + draft_sequence: 1, + per_page: 30, + tags: [ + { + id: 1, + name: "dev", + topic_count: 1, + }, + ], + topics: [], + }, + }); + }); + + server.get("/tags/intersection/dev/foo.json", () => { + return helper.response({ + topic_list: { + can_create_topic: true, + draft: null, + draft_key: "new_topic", + draft_sequence: 1, + per_page: 30, + topics: [], + }, + }); + }); + server.get("/u/search/users", () => { return helper.response({ users: [ @@ -810,13 +851,92 @@ acceptance("Search - assistant", function (needs) { query( ".search-menu .results ul.search-menu-assistant .search-item-prefix" ).innerText, - "sam " + "sam" ); await click(firstCategory); assert.strictEqual(query("#search-term").value, `sam #${firstResultSlug}`); }); + test("Shows category / tag combination shortcut when both are present", async function (assert) { + await visit("/tags/c/bug/dev"); + await click("#search-button"); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .category-name") + .innerText, + "bug", + "Category is displayed" + ); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .search-item-tag") + .innerText, + "dev", + "Tag is displayed" + ); + }); + + test("Updates tag / category combination search suggestion when typing", async function (assert) { + await visit("/tags/c/bug/dev"); + await click("#search-button"); + await fillIn("#search-term", "foo bar"); + + assert.strictEqual( + query( + ".search-menu .results ul.search-menu-assistant .search-item-prefix" + ).innerText, + "foo bar", + "Input is applied to search query" + ); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .category-name") + .innerText, + "bug" + ); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .search-item-tag") + .innerText, + "dev", + "Tag is displayed" + ); + }); + + test("Shows tag combination shortcut when visiting tag intersection", async function (assert) { + await visit("/tags/intersection/dev/foo"); + await click("#search-button"); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .search-item-tag") + .innerText, + "tags:dev+foo", + "Tags are displayed" + ); + }); + + test("Updates tag intersection search suggestion when typing", async function (assert) { + await visit("/tags/intersection/dev/foo"); + await click("#search-button"); + await fillIn("#search-term", "foo bar"); + + assert.strictEqual( + query( + ".search-menu .results ul.search-menu-assistant .search-item-prefix" + ).innerText, + "foo bar", + "Input is applied to search query" + ); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .search-item-tag") + .innerText, + "tags:dev+foo", + "Tags are displayed" + ); + }); + test("shows in: shortcuts", async function (assert) { await visit("/"); await click("#search-button"); diff --git a/app/assets/stylesheets/common/base/search-menu.scss b/app/assets/stylesheets/common/base/search-menu.scss index 189cee2a63a..95a2353e031 100644 --- a/app/assets/stylesheets/common/base/search-menu.scss +++ b/app/assets/stylesheets/common/base/search-menu.scss @@ -208,9 +208,13 @@ $search-pad-horizontal: 0.5em; margin-top: 2px; } - .search-item-slug .badge-wrapper { - font-size: var(--font-0); - margin-left: 2px; + .search-item-slug { + margin-right: 5px; + + .badge-wrapper { + font-size: var(--font-0); + margin-left: 2px; + } } .search-menu-initial-options { @@ -225,7 +229,13 @@ $search-pad-horizontal: 0.5em; .search-menu-initial-options, .search-result-tag, .search-menu-assistant { + .search-item-prefix { + padding-right: 5px; + } .search-link { + display: flex; + flex-wrap: wrap; + align-items: center; @include ellipsis; .d-icon { margin-right: 5px;