From 3a469a79cfb641ec7782a995ca1fd88be1f8dcf3 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 11 Oct 2019 11:37:44 -0400 Subject: [PATCH] FEATURE: search topics when adding a link in composer (#8178) --- .../controllers/insert-hyperlink.js.es6 | 151 ++++++++++++++++-- .../templates/modal/insert-hyperlink.hbs | 23 ++- app/assets/stylesheets/common/base/modal.scss | 33 ++++ config/locales/client.en.yml | 2 +- .../acceptance/composer-hyperlink-test.js.es6 | 53 ++++-- 5 files changed, 237 insertions(+), 25 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/insert-hyperlink.js.es6 b/app/assets/javascripts/discourse/controllers/insert-hyperlink.js.es6 index d4f15d95cfc..313ab332c3b 100644 --- a/app/assets/javascripts/discourse/controllers/insert-hyperlink.js.es6 +++ b/app/assets/javascripts/discourse/controllers/insert-hyperlink.js.es6 @@ -1,15 +1,140 @@ import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { searchForTerm } from "discourse/lib/search"; export default Ember.Controller.extend(ModalFunctionality, { - linkUrl: "", - linkText: "", + _debounced: null, + _activeSearch: null, onShow() { - Ember.run.next(() => - $(this) - .find("input.link-url") - .focus() - ); + this.setProperties({ + linkUrl: "", + linkText: "", + searchResults: [], + searchLoading: false, + selectedRow: -1 + }); + + Ember.run.scheduleOnce("afterRender", () => { + const element = document.querySelector(".insert-link"); + + element.addEventListener("keydown", e => this.keyDown(e)); + + element + .closest(".modal-inner-container") + .addEventListener("mousedown", e => this.mouseDown(e)); + + document.querySelector("input.link-url").focus(); + }); + }, + + keyDown(e) { + switch (e.which) { + case 40: + this.highlightRow(e, "down"); + break; + case 38: + this.highlightRow(e, "up"); + break; + case 13: + // override Enter behaviour when a row is selected + if (this.selectedRow > -1) { + const selected = document.querySelectorAll( + ".internal-link-results .search-link" + )[this.selectedRow]; + this.selectLink(selected); + e.preventDefault(); + e.stopPropagation(); + } + break; + case 27: + // Esc should cancel dropdown first + if (this.searchResults.length) { + this.set("searchResults", []); + e.preventDefault(); + e.stopPropagation(); + } + break; + } + }, + + mouseDown(e) { + if (!e.target.closest(".inputs")) { + this.set("searchResults", []); + } + }, + + highlightRow(e, direction) { + const index = + direction === "down" ? this.selectedRow + 1 : this.selectedRow - 1; + + if (index > -1 && index < this.searchResults.length) { + document + .querySelectorAll(".internal-link-results .search-link") + [index].focus(); + this.set("selectedRow", index); + } else { + this.set("selectedRow", -1); + document.querySelector("input.link-url").focus(); + } + + e.preventDefault(); + }, + + selectLink(el) { + this.setProperties({ + linkUrl: el.href, + searchResults: [], + selectedRow: -1 + }); + + if (!this.linkText && el.dataset.title) { + this.set("linkText", el.dataset.title); + } + + document.querySelector("input.link-text").focus(); + }, + + triggerSearch() { + if (this.linkUrl.length > 3 && this.linkUrl.indexOf("http") === -1) { + this.set("searchLoading", true); + this._activeSearch = searchForTerm(this.linkUrl, { + typeFilter: "topic" + }); + this._activeSearch + .then(results => { + if (results && results.topics && results.topics.length > 0) { + this.set("searchResults", results.topics); + } else { + this.set("searchResults", []); + } + }) + .finally(() => { + this.set("searchLoading", false); + this._activeSearch = null; + }); + } else { + this.abortSearch(); + } + }, + + abortSearch() { + if (this._activeSearch) { + this._activeSearch.abort(); + } + this.setProperties({ + searchResults: [], + searchLoading: false + }); + }, + + onClose() { + const element = document.querySelector(".insert-link"); + element.removeEventListener("keydown", this.keyDown); + element + .closest(".modal-inner-container") + .removeEventListener("mousedown", this.mouseDown); + + Ember.run.cancel(this._debounced); }, actions: { @@ -35,12 +160,20 @@ export default Ember.Controller.extend(ModalFunctionality, { this.toolbarEvent.selectText(sel.start + 1, origLink.length); } } - this.set("linkUrl", ""); - this.set("linkText", ""); this.send("closeModal"); }, cancel() { this.send("closeModal"); + }, + linkClick(e) { + if (!e.metaKey && !e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + this.selectLink(e.target); + } + }, + search() { + this._debounced = Ember.run.debounce(this, this.triggerSearch, 400); } } }); diff --git a/app/assets/javascripts/discourse/templates/modal/insert-hyperlink.hbs b/app/assets/javascripts/discourse/templates/modal/insert-hyperlink.hbs index 8aa06072666..ec15a2b5012 100644 --- a/app/assets/javascripts/discourse/templates/modal/insert-hyperlink.hbs +++ b/app/assets/javascripts/discourse/templates/modal/insert-hyperlink.hbs @@ -1,6 +1,27 @@ {{#d-modal-body title="composer.link_dialog_title" class="insert-link"}}
- {{text-field value=linkUrl placeholderKey="composer.link_url_placeholder" class="link-url"}} + {{text-field + value=linkUrl + placeholderKey="composer.link_url_placeholder" + class="link-url" + key-up=(action "search") + }} + {{#if searchLoading}} + {{loading-spinner}} + {{/if}} + {{#if searchResults}} + + {{/if}}
{{text-field value=linkText placeholderKey="composer.link_optional_text" class="link-text"}} diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index bf181bfc70a..44f7c2fbf22 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -201,9 +201,42 @@ } &.insert-link { + overflow-y: visible; input { min-width: 300px; } + + .inputs { + position: relative; + .spinner { + position: absolute; + right: 8px; + top: -15px; + width: 10px; + height: 10px; + } + .internal-link-results { + position: absolute; + top: 70%; + padding: 5px 10px; + box-shadow: shadow("card"); + z-index: 5; + background-color: $secondary; + max-height: 150px; + width: 90%; + overflow-y: auto; + > a { + padding: 6px; + border-bottom: 1px solid $primary-low; + cursor: pointer; + display: block; + &:hover, + &:focus { + background-color: $highlight-medium; + } + } + } + } } textarea { width: 99%; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index f30f1087fb6..df10b602077 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1685,7 +1685,7 @@ en: link_description: "enter link description here" link_dialog_title: "Insert Hyperlink" link_optional_text: "optional title" - link_url_placeholder: "https://example.com" + link_url_placeholder: "Paste a URL or type to search topics" quote_title: "Blockquote" quote_text: "Blockquote" code_title: "Preformatted text" diff --git a/test/javascripts/acceptance/composer-hyperlink-test.js.es6 b/test/javascripts/acceptance/composer-hyperlink-test.js.es6 index 52ff6c7d035..dd1914ed8c2 100644 --- a/test/javascripts/acceptance/composer-hyperlink-test.js.es6 +++ b/test/javascripts/acceptance/composer-hyperlink-test.js.es6 @@ -9,18 +9,13 @@ QUnit.test("add a hyperlink to a reply", async assert => { await click(".topic-post:first-child button.reply"); await fillIn(".d-editor-input", "This is a link to "); - assert.equal( - find(".insert-link.modal-body").length, - 0, + assert.ok( + !exists(".insert-link.modal-body"), "no hyperlink modal by default" ); await click(".d-editor button.link"); - assert.equal( - find(".insert-link.modal-body").length, - 1, - "hyperlink modal visible" - ); + assert.ok(exists(".insert-link.modal-body"), "hyperlink modal visible"); await fillIn(".modal-body .link-url", "google.com"); await fillIn(".modal-body .link-text", "Google"); @@ -32,9 +27,8 @@ QUnit.test("add a hyperlink to a reply", async assert => { "adds link with url and text, prepends 'http://'" ); - assert.equal( - find(".insert-link.modal-body").length, - 0, + assert.ok( + !exists(".insert-link.modal-body"), "modal dismissed after submitting link" ); @@ -51,9 +45,8 @@ QUnit.test("add a hyperlink to a reply", async assert => { "adds link with url and text, prepends 'http://'" ); - assert.equal( - find(".insert-link.modal-body").length, - 0, + assert.ok( + !exists(".insert-link.modal-body"), "modal dismissed after cancelling" ); @@ -70,4 +63,36 @@ QUnit.test("add a hyperlink to a reply", async assert => { "[Reset](http://somelink.com) textarea contents.", "adds link to a selected text" ); + + await fillIn(".d-editor-input", ""); + + await click(".d-editor button.link"); + await fillIn(".modal-body .link-url", "http://google.com"); + await keyEvent(".modal-body .link-url", "keyup", 32); + assert.ok( + !exists(".internal-link-results"), + "does not show internal links search dropdown when inputting a url" + ); + + await fillIn(".modal-body .link-url", "local"); + await keyEvent(".modal-body .link-url", "keyup", 32); + assert.ok( + exists(".internal-link-results"), + "shows internal links search dropdown when entering keywords" + ); + + await keyEvent(".insert-link", "keydown", 40); + await keyEvent(".insert-link", "keydown", 13); + + assert.ok( + !exists(".internal-link-results"), + "search dropdown dismissed after selecting an internal link" + ); + + assert.ok( + find(".link-url") + .val() + .includes("http"), + "replaces link url field with internal link" + ); });