FEATURE: search topics when adding a link in composer (#8178)
This commit is contained in:
parent
9a81cb9e55
commit
3a469a79cf
|
@ -1,15 +1,140 @@
|
||||||
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
||||||
|
import { searchForTerm } from "discourse/lib/search";
|
||||||
|
|
||||||
export default Ember.Controller.extend(ModalFunctionality, {
|
export default Ember.Controller.extend(ModalFunctionality, {
|
||||||
linkUrl: "",
|
_debounced: null,
|
||||||
linkText: "",
|
_activeSearch: null,
|
||||||
|
|
||||||
onShow() {
|
onShow() {
|
||||||
Ember.run.next(() =>
|
this.setProperties({
|
||||||
$(this)
|
linkUrl: "",
|
||||||
.find("input.link-url")
|
linkText: "",
|
||||||
.focus()
|
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: {
|
actions: {
|
||||||
|
@ -35,12 +160,20 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
||||||
this.toolbarEvent.selectText(sel.start + 1, origLink.length);
|
this.toolbarEvent.selectText(sel.start + 1, origLink.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.set("linkUrl", "");
|
|
||||||
this.set("linkText", "");
|
|
||||||
this.send("closeModal");
|
this.send("closeModal");
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
this.send("closeModal");
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,27 @@
|
||||||
{{#d-modal-body title="composer.link_dialog_title" class="insert-link"}}
|
{{#d-modal-body title="composer.link_dialog_title" class="insert-link"}}
|
||||||
<div class="inputs">
|
<div class="inputs">
|
||||||
{{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}}
|
||||||
|
<div class="internal-link-results">
|
||||||
|
{{#each searchResults as |r index|}}
|
||||||
|
<a
|
||||||
|
class="search-link"
|
||||||
|
href="{{r.url}}"
|
||||||
|
onclick={{action "linkClick"}}
|
||||||
|
data-title="{{r.title}}">
|
||||||
|
{{replace-emoji r.fancy_title}}
|
||||||
|
</a>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
<div class="inputs">
|
<div class="inputs">
|
||||||
{{text-field value=linkText placeholderKey="composer.link_optional_text" class="link-text"}}
|
{{text-field value=linkText placeholderKey="composer.link_optional_text" class="link-text"}}
|
||||||
|
|
|
@ -201,9 +201,42 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&.insert-link {
|
&.insert-link {
|
||||||
|
overflow-y: visible;
|
||||||
input {
|
input {
|
||||||
min-width: 300px;
|
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 {
|
textarea {
|
||||||
width: 99%;
|
width: 99%;
|
||||||
|
|
|
@ -1685,7 +1685,7 @@ en:
|
||||||
link_description: "enter link description here"
|
link_description: "enter link description here"
|
||||||
link_dialog_title: "Insert Hyperlink"
|
link_dialog_title: "Insert Hyperlink"
|
||||||
link_optional_text: "optional title"
|
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_title: "Blockquote"
|
||||||
quote_text: "Blockquote"
|
quote_text: "Blockquote"
|
||||||
code_title: "Preformatted text"
|
code_title: "Preformatted text"
|
||||||
|
|
|
@ -9,18 +9,13 @@ QUnit.test("add a hyperlink to a reply", async assert => {
|
||||||
await click(".topic-post:first-child button.reply");
|
await click(".topic-post:first-child button.reply");
|
||||||
await fillIn(".d-editor-input", "This is a link to ");
|
await fillIn(".d-editor-input", "This is a link to ");
|
||||||
|
|
||||||
assert.equal(
|
assert.ok(
|
||||||
find(".insert-link.modal-body").length,
|
!exists(".insert-link.modal-body"),
|
||||||
0,
|
|
||||||
"no hyperlink modal by default"
|
"no hyperlink modal by default"
|
||||||
);
|
);
|
||||||
|
|
||||||
await click(".d-editor button.link");
|
await click(".d-editor button.link");
|
||||||
assert.equal(
|
assert.ok(exists(".insert-link.modal-body"), "hyperlink modal visible");
|
||||||
find(".insert-link.modal-body").length,
|
|
||||||
1,
|
|
||||||
"hyperlink modal visible"
|
|
||||||
);
|
|
||||||
|
|
||||||
await fillIn(".modal-body .link-url", "google.com");
|
await fillIn(".modal-body .link-url", "google.com");
|
||||||
await fillIn(".modal-body .link-text", "Google");
|
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://'"
|
"adds link with url and text, prepends 'http://'"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(
|
assert.ok(
|
||||||
find(".insert-link.modal-body").length,
|
!exists(".insert-link.modal-body"),
|
||||||
0,
|
|
||||||
"modal dismissed after submitting link"
|
"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://'"
|
"adds link with url and text, prepends 'http://'"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(
|
assert.ok(
|
||||||
find(".insert-link.modal-body").length,
|
!exists(".insert-link.modal-body"),
|
||||||
0,
|
|
||||||
"modal dismissed after cancelling"
|
"modal dismissed after cancelling"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -70,4 +63,36 @@ QUnit.test("add a hyperlink to a reply", async assert => {
|
||||||
"[Reset](http://somelink.com) textarea contents.",
|
"[Reset](http://somelink.com) textarea contents.",
|
||||||
"adds link to a selected text"
|
"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"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue