diff --git a/app/assets/javascripts/discourse/app/components/d-editor.gjs b/app/assets/javascripts/discourse/app/components/d-editor.gjs index 3608a7c6904..0d726921235 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.gjs +++ b/app/assets/javascripts/discourse/app/components/d-editor.gjs @@ -38,7 +38,7 @@ import loadEmojiSearchAliases from "discourse/lib/load-emoji-search-aliases"; import loadRichEditor from "discourse/lib/load-rich-editor"; import { rovingButtonBar } from "discourse/lib/roving-button-bar"; import { emojiUrlFor, generateCookFunction } from "discourse/lib/text"; -import userSearch from "discourse/lib/user-search"; +import userSearch, { validateSearchResult } from "discourse/lib/user-search"; import { destroyUserStatuses, initUserStatusHtml, @@ -463,7 +463,10 @@ export default class DEditor extends Component { }, onRender: (options) => renderUserStatusHtml(options), key: "@", - transformComplete: (v) => v.username || v.name, + transformComplete: (v) => { + validateSearchResult(v); + return v.username || v.name; + }, afterComplete: () => { schedule( "afterRender", diff --git a/app/assets/javascripts/discourse/app/lib/search.js b/app/assets/javascripts/discourse/app/lib/search.js index 272694cdc84..0b5139be92a 100644 --- a/app/assets/javascripts/discourse/app/lib/search.js +++ b/app/assets/javascripts/discourse/app/lib/search.js @@ -7,7 +7,7 @@ import getURL from "discourse/lib/get-url"; import { deepMerge } from "discourse/lib/object"; import { emojiUnescape } from "discourse/lib/text"; import { userPath } from "discourse/lib/url"; -import userSearch from "discourse/lib/user-search"; +import userSearch, { validateSearchResult } from "discourse/lib/user-search"; import { escapeExpression } from "discourse/lib/utilities"; import Category from "discourse/models/category"; import Post from "discourse/models/post"; @@ -26,6 +26,7 @@ const logSearchLinkClickedCallbacks = []; export function addLogSearchLinkClickedCallbacks(fn) { logSearchLinkClickedCallbacks.push(fn); } + export function resetLogSearchLinkClickedCallbacks() { logSearchLinkClickedCallbacks.clear(); } @@ -244,7 +245,10 @@ export function applySearchAutocomplete($input, siteSettings) { width: "100%", treatAsTextarea: true, autoSelectFirstSuggestion: false, - transformComplete: (v) => v.username || v.name, + transformComplete: (v) => { + validateSearchResult(v); + return v.username || v.name; + }, dataSource: (term) => userSearch({ term, includeGroups: true }), }) ); diff --git a/app/assets/javascripts/discourse/app/lib/user-search.js b/app/assets/javascripts/discourse/app/lib/user-search.js index 0f88f6018e6..41dc7014489 100644 --- a/app/assets/javascripts/discourse/app/lib/user-search.js +++ b/app/assets/javascripts/discourse/app/lib/user-search.js @@ -9,6 +9,7 @@ import { cloneJSON } from "discourse/lib/object"; import { userPath } from "discourse/lib/url"; import { emailValid } from "discourse/lib/utilities"; import { CANCELLED_STATUS } from "discourse/modifiers/d-autocomplete"; +import { i18n } from "discourse-i18n"; let cache = {}, cacheKey, @@ -373,3 +374,27 @@ export default function userSearch(options) { ); }); } + +export function validateSearchResult(obj) { + const expectedPropertiesMap = { + isUser: { + nameProperty: "username", + translateKey: "composer.autocomplete.username_missing", + }, + isEmail: { + nameProperty: "username", + translateKey: "composer.autocomplete.username_missing", + }, + isGroup: { + nameProperty: "name", + translateKey: "composer.autocomplete.name_missing", + }, + }; + + for (const [isEntity, props] of Object.entries(expectedPropertiesMap)) { + if (obj[isEntity] && !obj[props.nameProperty]) { + throw new Error(i18n(props.translateKey)); + } + } + return true; +} diff --git a/app/assets/javascripts/discourse/app/modifiers/d-autocomplete.js b/app/assets/javascripts/discourse/app/modifiers/d-autocomplete.js index 25bd9c7b4ea..220ab628b5b 100644 --- a/app/assets/javascripts/discourse/app/modifiers/d-autocomplete.js +++ b/app/assets/javascripts/discourse/app/modifiers/d-autocomplete.js @@ -5,6 +5,7 @@ import { cancel } from "@ember/runloop"; import { service } from "@ember/service"; import Modifier from "ember-modifier"; import DAutocompleteResults from "discourse/components/d-autocomplete-results"; +import { extractError } from "discourse/lib/ajax-error"; import discourseDebounce from "discourse/lib/debounce"; import { INPUT_DELAY } from "discourse/lib/environment"; import { VISIBILITY_OPTIMIZERS } from "float-kit/lib/constants"; @@ -54,6 +55,7 @@ export default class DAutocompleteModifier extends Modifier { } @service menu; + @service toasts; @tracked expanded = false; @tracked results = []; @@ -488,7 +490,17 @@ export default class DAutocompleteModifier extends Modifier { // Transform if needed if (this.options.transformComplete) { - term = await this.options.transformComplete(term, event); + try { + term = await this.options.transformComplete(term, event); + } catch (e) { + this.toasts.error({ + duration: "short", + data: { + message: extractError(e, e.message), + }, + }); + return; + } } if (!term) { diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ff04013dd7e..31f416a77cf 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2880,6 +2880,10 @@ en: publishing: "Publishing Topic…" composer: + autocomplete: + username_missing: "Selected autocomplete result is missing username." + name_missing: "Selected autocomplete result is missing name." + emoji: "Emoji :)" more_emoji: "more…" options: "Options" diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-composer.gjs index 3f98d2a0aa2..990b88d7860 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.gjs @@ -33,7 +33,7 @@ import { cloneJSON } from "discourse/lib/object"; import optionalService from "discourse/lib/optional-service"; import { emojiUrlFor } from "discourse/lib/text"; import { TextareaAutocompleteHandler } from "discourse/lib/textarea-text-manipulation"; -import userSearch from "discourse/lib/user-search"; +import userSearch, { validateSearchResult } from "discourse/lib/user-search"; import { destroyUserStatuses, initUserStatusHtml, @@ -482,7 +482,7 @@ export default class ChatComposer extends Component { if (obj.isUser) { this.#addMentionedUser(cloneJSON(obj)); } - + validateSearchResult(obj); return obj.username || obj.name; }, dataSource: (term) => { diff --git a/plugins/discourse-ai/assets/javascripts/discourse/components/ai-bot-conversations.gjs b/plugins/discourse-ai/assets/javascripts/discourse/components/ai-bot-conversations.gjs index 5604681ac6a..905cef33ec8 100644 --- a/plugins/discourse-ai/assets/javascripts/discourse/components/ai-bot-conversations.gjs +++ b/plugins/discourse-ai/assets/javascripts/discourse/components/ai-bot-conversations.gjs @@ -19,7 +19,7 @@ import userAutocomplete from "discourse/lib/autocomplete/user"; import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete"; import UppyUpload from "discourse/lib/uppy/uppy-upload"; import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin"; -import userSearch from "discourse/lib/user-search"; +import userSearch, { validateSearchResult } from "discourse/lib/user-search"; import { destroyUserStatuses, initUserStatusHtml, @@ -204,7 +204,10 @@ export default class AiBotConversations extends Component { width: "100%", treatAsTextarea: true, autoSelectFirstSuggestion: true, - transformComplete: (obj) => obj.username || obj.name, + transformComplete: (obj) => { + validateSearchResult(obj); + return obj.username || obj.name; + }, afterComplete: (text) => { this.textarea.value = text; this.focusTextarea();