From 282a544cce55bfd1c445e8ac8970841a23e8f98a Mon Sep 17 00:00:00 2001 From: Kelv Date: Thu, 14 Aug 2025 09:12:47 +0800 Subject: [PATCH] DEV: raise error on missing name properties for user autocomplete results (#34283) Related to https://github.com/discourse/discourse/pull/34208. Previously, missing name properties for a selected user search result would fail silently which made it difficult to tell if something had gone wrong with the user autocomplete. This PR adds some error handling to make it more obvious that an error has occurred. I've opted for using `toasts` here since it's less intrusive compared to the `dialog` modal. https://github.com/user-attachments/assets/92f585d8-95bf-4c5b-a08a-698c1a527b79 --- .../discourse/app/components/d-editor.gjs | 7 ++++-- .../javascripts/discourse/app/lib/search.js | 8 ++++-- .../discourse/app/lib/user-search.js | 25 +++++++++++++++++++ .../discourse/app/modifiers/d-autocomplete.js | 14 ++++++++++- config/locales/client.en.yml | 4 +++ .../discourse/components/chat-composer.gjs | 4 +-- .../components/ai-bot-conversations.gjs | 7 ++++-- 7 files changed, 60 insertions(+), 9 deletions(-) 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();