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
This commit is contained in:
Kelv
2025-08-14 09:12:47 +08:00
committed by GitHub
parent 49e85e9018
commit 282a544cce
7 changed files with 60 additions and 9 deletions
@@ -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",
@@ -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 }),
})
);
@@ -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;
}
@@ -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) {
+4
View File
@@ -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"
@@ -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) => {
@@ -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();