DEV: Migrate insert-hyperlink to the new modal api (#23051)

This commit is contained in:
Jarek Radosz 2023-08-10 12:09:26 +02:00 committed by GitHub
parent c280c1c52b
commit bc26e6c4b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 304 additions and 311 deletions

View File

@ -32,7 +32,6 @@ import { loadOneboxes } from "discourse/lib/load-oneboxes";
import loadScript from "discourse/lib/load-script";
import { resolveCachedShortUrls } from "pretty-text/upload-short-url";
import { inject as service } from "@ember/service";
import showModal from "discourse/lib/show-modal";
import { siteDir } from "discourse/lib/text-direction";
import { translations } from "pretty-text/emoji/data";
import { wantsNewWindow } from "discourse/lib/intercept-click";
@ -40,6 +39,7 @@ import { action, computed } from "@ember/object";
import TextareaTextManipulation, {
getHead,
} from "discourse/mixins/textarea-text-manipulation";
import InsertHyperlink from "discourse/components/modal/insert-hyperlink";
function getButtonLabel(labelKey, defaultLabel) {
// use the Font Awesome icon if the label matches the default
@ -218,6 +218,9 @@ export function onToolbarCreate(func) {
}
export default Component.extend(TextareaTextManipulation, {
emojiStore: service("emoji-store"),
modal: service(),
classNames: ["d-editor"],
ready: false,
lastSel: null,
@ -225,7 +228,6 @@ export default Component.extend(TextareaTextManipulation, {
showLink: true,
emojiPickerIsActive: false,
emojiFilter: "",
emojiStore: service("emoji-store"),
isEditorFocused: false,
processPreview: true,
composerFocusSelector: "#reply-control .d-editor-input",
@ -770,9 +772,11 @@ export default Component.extend(TextareaTextManipulation, {
linkText = this._lastSel.value;
}
showModal("insert-hyperlink").setProperties({
linkText,
toolbarEvent,
this.modal.show(InsertHyperlink, {
model: {
linkText,
toolbarEvent,
},
});
},

View File

@ -0,0 +1,74 @@
{{! template-lint-disable no-pointer-down-event-binding }}
<DModal
{{on "keydown" this.keyDown}}
{{on "mousedown" this.mouseDown}}
@closeModal={{@closeModal}}
@title={{i18n "composer.link_dialog_title"}}
@bodyClass="insert-link"
class="insert-hyperlink-modal"
>
<:body>
<div class="inputs">
<input
{{on "input" this.search}}
value={{this.linkUrl}}
placeholder={{i18n "composer.link_url_placeholder"}}
type="text"
autofocus="autofocus"
class="link-url"
/>
{{#if this.searchLoading}}
{{loading-spinner}}
{{/if}}
{{#if this.searchResults}}
<div class="internal-link-results">
{{#each this.searchResults as |result|}}
<a
{{on "click" this.linkClick}}
href={{result.url}}
data-title={{result.fancy_title}}
class="search-link"
>
<TopicStatus @topic={{result}} @disableActions={{true}} />
{{replace-emoji result.title}}
<div class="search-category">
{{#if result.category.parentCategory}}
{{category-link result.category.parentCategory}}
{{/if}}
{{category-link result.category hideParent=true}}
{{discourse-tags result}}
</div>
</a>
{{/each}}
</div>
{{/if}}
</div>
<div class="inputs">
<input
{{on "input" this.updateLinkText}}
value={{this.linkText}}
placeholder={{i18n "composer.link_optional_text"}}
type="text"
class="link-text"
/>
</div>
</:body>
<:footer>
<DButton
@action={{this.ok}}
@label="composer.modal_ok"
type="submit"
class="btn-primary"
/>
<DButton
@action={{@closeModal}}
@label="composer.modal_cancel"
class="btn-danger"
/>
</:footer>
</DModal>

View File

@ -0,0 +1,164 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { cancel } from "@ember/runloop";
import discourseDebounce from "discourse-common/lib/debounce";
import { isEmpty } from "@ember/utils";
import { prefixProtocol } from "discourse/lib/url";
import { searchForTerm } from "discourse/lib/search";
export default class InsertHyperlink extends Component {
@tracked linkText = this.args.model.linkText;
@tracked linkUrl = "";
@tracked selectedRow = -1;
@tracked searchResults = [];
@tracked searchLoading = false;
_debounced;
_activeSearch;
willDestroy() {
super.willDestroy(...arguments);
cancel(this._debounced);
}
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.selectedRow = index;
} else {
this.selectedRow = -1;
document.querySelector("input.link-url").focus();
}
e.preventDefault();
}
selectLink(el) {
this.searchResults = [];
this.linkUrl = el.href;
this.selectedRow = -1;
if (!this.linkText && el.dataset.title) {
this.linkText = el.dataset.title;
}
document.querySelector("input.link-text").focus();
}
async triggerSearch() {
if (this.linkUrl.length < 4 || this.linkUrl.startsWith("http")) {
this.abortSearch();
return;
}
this.searchLoading = true;
this._activeSearch = searchForTerm(this.linkUrl, {
typeFilter: "topic",
});
try {
const results = await this._activeSearch;
this.searchResults = results?.topics || [];
} finally {
this.searchLoading = false;
this._activeSearch = null;
}
}
abortSearch() {
this._activeSearch?.abort();
this.searchResults = [];
this.searchLoading = false;
}
@action
keyDown(event) {
switch (event.key) {
case "ArrowDown":
this.highlightRow(event, "down");
break;
case "ArrowUp":
this.highlightRow(event, "up");
break;
case "Enter":
// override Enter behavior when a row is selected
if (this.selectedRow > -1) {
const selected = document.querySelectorAll(
".internal-link-results .search-link"
)[this.selectedRow];
this.selectLink(selected);
event.preventDefault();
event.stopPropagation();
}
break;
case "Escape":
// Esc should cancel dropdown first
if (this.searchResults.length) {
this.searchResults = [];
event.preventDefault();
event.stopPropagation();
} else {
this.args.closeModal();
document.querySelector(".d-editor-input")?.focus();
}
break;
}
}
@action
mouseDown(event) {
if (!event.target.closest(".inputs")) {
this.searchResults = [];
}
}
@action
ok() {
const origLink = this.linkUrl;
const linkUrl = prefixProtocol(origLink);
const sel = this.args.model.toolbarEvent.selected;
if (isEmpty(linkUrl)) {
return;
}
const linkText = this.linkText || "";
if (linkText.length) {
this.args.model.toolbarEvent.addText(`[${linkText}](${linkUrl})`);
} else if (sel.value) {
this.args.model.toolbarEvent.addText(`[${sel.value}](${linkUrl})`);
} else {
this.args.model.toolbarEvent.addText(`[${origLink}](${linkUrl})`);
this.args.model.toolbarEvent.selectText(sel.start + 1, origLink.length);
}
this.args.closeModal();
}
@action
linkClick(e) {
if (!e.metaKey && !e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
this.selectLink(e.target.closest(".search-link"));
}
}
@action
updateLinkText(event) {
this.linkText = event.target.value;
}
@action
search(event) {
this.linkUrl = event.target.value;
this._debounced = discourseDebounce(this, this.triggerSearch, 400);
}
}

View File

@ -1,186 +0,0 @@
import { cancel, schedule } from "@ember/runloop";
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { bind } from "discourse-common/utils/decorators";
import discourseDebounce from "discourse-common/lib/debounce";
import { isEmpty } from "@ember/utils";
import { prefixProtocol } from "discourse/lib/url";
import { searchForTerm } from "discourse/lib/search";
export default Controller.extend(ModalFunctionality, {
_debounced: null,
_activeSearch: null,
onShow() {
this.setProperties({
linkUrl: "",
linkText: "",
searchResults: [],
searchLoading: false,
selectedRow: -1,
});
schedule("afterRender", () => {
const element = document.querySelector(".insert-link");
element.addEventListener("keydown", this.keyDown);
element
.closest(".modal-inner-container")
.addEventListener("mousedown", this.mouseDown);
});
},
@bind
keyDown(event) {
switch (event.which) {
case 40:
this.highlightRow(event, "down");
break;
case 38:
this.highlightRow(event, "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);
event.preventDefault();
event.stopPropagation();
}
break;
case 27:
// Esc should cancel dropdown first
if (this.searchResults.length) {
this.set("searchResults", []);
event.preventDefault();
event.stopPropagation();
} else {
this.send("closeModal");
document.querySelector(".d-editor-input")?.focus();
}
break;
}
},
@bind
mouseDown(event) {
if (!event.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.startsWith("http")) {
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);
cancel(this._debounced);
},
actions: {
ok() {
const origLink = this.linkUrl;
const linkUrl = prefixProtocol(origLink);
const sel = this.toolbarEvent.selected;
if (isEmpty(linkUrl)) {
return;
}
const linkText = this.linkText || "";
if (linkText.length) {
this.toolbarEvent.addText(`[${linkText}](${linkUrl})`);
} else {
if (sel.value) {
this.toolbarEvent.addText(`[${sel.value}](${linkUrl})`);
} else {
this.toolbarEvent.addText(`[${origLink}](${linkUrl})`);
this.toolbarEvent.selectText(sel.start + 1, origLink.length);
}
}
this.send("closeModal");
},
cancel() {
this.send("closeModal");
},
linkClick(e) {
if (!e.metaKey && !e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
this.selectLink(e.target.closest(".search-link"));
}
},
search() {
this._debounced = discourseDebounce(this, this.triggerSearch, 400);
},
},
});

View File

@ -33,7 +33,6 @@ const KNOWN_LEGACY_MODALS = [
"history",
"ignore-duration-with-username",
"ignore-duration",
"insert-hyperlink",
"jump-to-post",
"login",
"move-to-topic",

View File

@ -1,61 +0,0 @@
<DModalBody @title="composer.link_dialog_title" @class="insert-link">
<form id="insert-hyperlink-form" {{on "submit" (action "ok")}}>
<div class="inputs">
<TextField
@value={{this.linkUrl}}
@placeholderKey="composer.link_url_placeholder"
@class="link-url"
@key-up={{action "search"}}
@autofocus="autofocus"
/>
{{#if this.searchLoading}}
{{loading-spinner}}
{{/if}}
{{#if this.searchResults}}
<div class="internal-link-results">
{{#each this.searchResults as |result|}}
<a
class="search-link"
href={{result.url}}
onclick={{action "linkClick"}}
data-title={{result.fancy_title}}
>
<TopicStatus @topic={{result}} @disableActions={{true}} />
{{replace-emoji result.title}}
<div class="search-category">
{{#if result.category.parentCategory}}
{{category-link result.category.parentCategory}}
{{/if}}
{{category-link result.category hideParent=true}}
{{discourse-tags result}}
</div>
</a>
{{/each}}
</div>
{{/if}}
</div>
<div class="inputs">
<TextField
@value={{this.linkText}}
@placeholderKey="composer.link_optional_text"
@class="link-text"
/>
</div>
</form>
</DModalBody>
<div class="modal-footer">
<DButton
@class="btn-primary"
@label="composer.modal_ok"
@action={{action "ok"}}
@type="submit"
@form="insert-hyperlink-form"
/>
<DButton
@class="btn-danger"
@label="composer.modal_cancel"
@action={{action "cancel"}}
/>
</div>

View File

@ -1,8 +1,4 @@
import {
acceptance,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers";
import { test } from "qunit";
@ -14,28 +10,27 @@ acceptance("Composer - Hyperlink", function (needs) {
await click(".topic-post:first-child button.reply");
await fillIn(".d-editor-input", "This is a link to ");
assert.ok(
!exists(".insert-link.modal-body"),
"no hyperlink modal by default"
);
assert
.dom(".insert-link.modal-body")
.doesNotExist("no hyperlink modal by default");
await click(".d-editor button.link");
assert.ok(exists(".insert-link.modal-body"), "hyperlink modal visible");
assert.dom(".insert-link.modal-body").exists("hyperlink modal visible");
await fillIn(".modal-body .link-url", "google.com");
await fillIn(".modal-body .link-text", "Google");
await click(".modal-footer button.btn-primary");
assert.strictEqual(
query(".d-editor-input").value,
"This is a link to [Google](https://google.com)",
"adds link with url and text, prepends 'https://'"
);
assert
.dom(".d-editor-input")
.hasValue(
"This is a link to [Google](https://google.com)",
"adds link with url and text, prepends 'https://'"
);
assert.ok(
!exists(".insert-link.modal-body"),
"modal dismissed after submitting link"
);
assert
.dom(".insert-link.modal-body")
.doesNotExist("modal dismissed after submitting link");
await fillIn(".d-editor-input", "Reset textarea contents.");
@ -44,16 +39,16 @@ acceptance("Composer - Hyperlink", function (needs) {
await fillIn(".modal-body .link-text", "Google");
await click(".modal-footer button.btn-danger");
assert.strictEqual(
query(".d-editor-input").value,
"Reset textarea contents.",
"doesnt insert anything after cancelling"
);
assert
.dom(".d-editor-input")
.hasValue(
"Reset textarea contents.",
"doesnt insert anything after cancelling"
);
assert.ok(
!exists(".insert-link.modal-body"),
"modal dismissed after cancelling"
);
assert
.dom(".insert-link.modal-body")
.doesNotExist("modal dismissed after cancelling");
const textarea = query("#reply-control .d-editor-input");
textarea.selectionStart = 0;
@ -63,48 +58,49 @@ acceptance("Composer - Hyperlink", function (needs) {
await fillIn(".modal-body .link-url", "somelink.com");
await click(".modal-footer button.btn-primary");
assert.strictEqual(
query(".d-editor-input").value,
"[Reset](https://somelink.com) textarea contents.",
"adds link to a selected text"
);
assert
.dom(".d-editor-input")
.hasValue(
"[Reset](https://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 triggerKeyEvent(".modal-body .link-url", "keyup", "Space");
assert.ok(
!exists(".internal-link-results"),
"does not show internal links search dropdown when inputting a url"
);
assert
.dom(".internal-link-results")
.doesNotExist(
"does not show internal links search dropdown when inputting a url"
);
await fillIn(".modal-body .link-url", "local");
await triggerKeyEvent(".modal-body .link-url", "keyup", "Space");
assert.ok(
exists(".internal-link-results"),
"shows internal links search dropdown when entering keywords"
);
assert
.dom(".internal-link-results")
.exists("shows internal links search dropdown when entering keywords");
await triggerKeyEvent(".insert-link", "keydown", "ArrowDown");
await triggerKeyEvent(".insert-link", "keydown", "Enter");
assert.ok(
!exists(".internal-link-results"),
"search dropdown dismissed after selecting an internal link"
);
assert
.dom(".internal-link-results")
.doesNotExist(
"search dropdown dismissed after selecting an internal link"
);
assert.ok(
query(".link-url").value.includes("http"),
"replaces link url field with internal link"
);
assert
.dom(".link-url")
.hasValue(/http/, "replaces link url field with internal link");
await triggerKeyEvent(".insert-link", "keydown", "Escape");
assert.strictEqual(
document.activeElement.classList.contains("d-editor-input"),
true,
"focus stays on composer after dismissing modal using Esc key"
);
assert
.dom(".d-editor-input")
.isFocused(
"focus stays on composer after dismissing modal using Esc key"
);
});
});

View File

@ -26,6 +26,7 @@ import {
renderUserStatusHtml,
} from "discourse/lib/user-status-on-autocomplete";
import ChatModalChannelSummary from "discourse/plugins/chat/discourse/components/chat/modal/channel-summary";
import InsertHyperlink from "discourse/components/modal/insert-hyperlink";
export default class ChatComposer extends Component {
@service capabilities;
@ -347,10 +348,12 @@ export default class ChatComposer extends Component {
const selected = this.composer.textarea.getSelected("", { lineVal: true });
const linkText = selected?.value;
showModal("insert-hyperlink").setProperties({
linkText,
toolbarEvent: {
addText: (text) => this.composer.textarea.addText(selected, text),
this.modal.show(InsertHyperlink, {
model: {
linkText,
toolbarEvent: {
addText: (text) => this.composer.textarea.addText(selected, text),
},
},
});
}