diff --git a/Gemfile.lock b/Gemfile.lock index c7ebc2cc8196f..7ae098ef89d45 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -233,7 +233,7 @@ GEM reline (>= 0.4.2) iso8601 (0.13.0) jmespath (1.6.2) - json (2.13.1) + json (2.13.2) json-schema (5.2.1) addressable (~> 2.8) bigdecimal (~> 3.1) @@ -286,7 +286,7 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0722) + mime-types-data (3.2025.0729) mini_mime (1.1.5) mini_racer (0.19.0) libv8-node (~> 24.1.0.0) @@ -991,7 +991,7 @@ CHECKSUMS irb (1.15.2) sha256=222f32952e278da34b58ffe45e8634bf4afc2dc7aa9da23fed67e581aa50fdba iso8601 (0.13.0) sha256=298c2b15b7be5fa95a1372813d36a2257656cd8e906dfbc1f5cb409851425aa2 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 - json (2.13.1) sha256=8e7cf4bb44a789ac5fc62869c7187986251c3172d9bdfa60b92c63d6ecab0ffc + json (2.13.2) sha256=02e1f118d434c6b230a64ffa5c8dee07e3ec96244335c392eaed39e1199dbb68 json-schema (5.2.1) sha256=1ef39286c4771e7a71661d955fec9b66d1d1708547a0071c130af7c0a9264898 json_schemer (2.4.0) sha256=56cb6117bb5748d925b33ad3f415b513d41d25d0bbf57fe63c0a78ff05597c24 jwt (2.10.1) sha256=e6424ae1d813f63e761a04d6284e10e7ec531d6f701917fadcd0d9b2deaf1cc5 @@ -1021,7 +1021,7 @@ CHECKSUMS messageformat-wrapper (1.1.0) sha256=ecea879626e412d1bc841c457dacfcbb1a62cf88ca83573e4ea34bb371f160bc method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 - mime-types-data (3.2025.0722) sha256=f9d1fd57ecc5688a66d9811d45981ee58e2dca012e352a1eaa7299e8c0f482f4 + mime-types-data (3.2025.0729) sha256=8d7e1ab1ab756ebba91354ff4e35bcf23c39ed86dc5abba6cf32ce66ee9e5aad mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef mini_racer (0.19.0) sha256=9152694738db8b145ced843126e2a391f908732c069cb97bbc909c2bac16bbc1 mini_scheduler (0.18.0) sha256=d2f084f38da8d76c5844a92f0d6bd01fc9982a8b5e6c7679b6cf44c82da33503 diff --git a/app/assets/javascripts/admin/addon/components/admin-plugins-list-item.gjs b/app/assets/javascripts/admin/addon/components/admin-plugins-list-item.gjs index d394ad1a6253a..1c4cc3d10c024 100644 --- a/app/assets/javascripts/admin/addon/components/admin-plugins-list-item.gjs +++ b/app/assets/javascripts/admin/addon/components/admin-plugins-list-item.gjs @@ -121,14 +121,9 @@ export default class AdminPluginsListItem extends Component { > {{@plugin.version}}
{{#if this.isPreinstalled}} - + {{i18n "admin.plugins.preinstalled"}} - + {{else}} {{/if}} diff --git a/app/assets/javascripts/admin/addon/models/report.js b/app/assets/javascripts/admin/addon/models/report.js index 3e79bfc7673f9..367b2fc7baa03 100644 --- a/app/assets/javascripts/admin/addon/models/report.js +++ b/app/assets/javascripts/admin/addon/models/report.js @@ -622,7 +622,7 @@ export default class Report extends EmberObject { _percentLabel(value) { return { value: toNumber(value), - formattedValue: value ? `${value}%` : "—", + formattedValue: value === null || value === undefined ? "—" : `${value}%`, }; } diff --git a/app/assets/javascripts/admin/addon/templates/plugins-index.gjs b/app/assets/javascripts/admin/addon/templates/plugins-index.gjs index b3862ce657451..7138a2a854ed1 100644 --- a/app/assets/javascripts/admin/addon/templates/plugins-index.gjs +++ b/app/assets/javascripts/admin/addon/templates/plugins-index.gjs @@ -1,9 +1,10 @@ +import { concat } from "@ember/helper"; +import { htmlSafe } from "@ember/template"; import RouteTemplate from "ember-route-template"; import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item"; import DPageHeader from "discourse/components/d-page-header"; import NavItem from "discourse/components/nav-item"; import PluginOutlet from "discourse/components/plugin-outlet"; -import icon from "discourse/helpers/d-icon"; import lazyHash from "discourse/helpers/lazy-hash"; import { i18n } from "discourse-i18n"; import AdminFilterControls from "admin/components/admin-filter-controls"; @@ -15,8 +16,14 @@ export default RouteTemplate( ' + (i18n "admin.plugins.howto") + "" + ) + }} > <:breadcrumbs> @@ -48,13 +55,6 @@ export default RouteTemplate( -
- - {{icon "circle-info"}} - {{i18n "admin.plugins.howto"}} - -
- {{#if @controller.model.length}} {{#if this.canCreateTopic}} @@ -16,11 +26,20 @@ export default class CreateTopicButton extends Component { + <:tooltip> + {{#if @disabled}} + + {{/if}} + {{#if @showDrafts}} diff --git a/app/assets/javascripts/discourse/app/components/d-autocomplete-results.gjs b/app/assets/javascripts/discourse/app/components/d-autocomplete-results.gjs new file mode 100644 index 0000000000000..75c47ec588a93 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-autocomplete-results.gjs @@ -0,0 +1,151 @@ +import Component from "@glimmer/component"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import { htmlSafe } from "@ember/template"; + +// CSS selectors for autocomplete result items +const RESULT_ITEM_SELECTOR = "li a"; +const SELECTED_RESULT_SELECTOR = "li a.selected"; +const SELECTED_CLASS = "selected"; + +/** + * Component for rendering autocomplete results in a d-menu + * + * @component DAutocompleteResults + * @param {Array} data.results - Array of autocomplete results + * @param {number} data.selectedIndex - Currently selected index + * @param {Function} data.onSelect - Callback for item selection + * @param {Function} data.template - Template function for rendering + */ +export default class DAutocompleteResults extends Component { + isInitialRender = true; + + get results() { + return this.args.data.getResults?.() || []; + } + + get selectedIndex() { + return this.args.data.getSelectedIndex?.() || 0; + } + + _applySelectedClass(wrapperElement, selectedIndex) { + const links = wrapperElement.querySelectorAll(RESULT_ITEM_SELECTOR); + + // Always remove existing selected classes first + const selectedElements = wrapperElement.querySelectorAll( + SELECTED_RESULT_SELECTOR + ); + selectedElements.forEach((element) => + element.classList.remove(SELECTED_CLASS) + ); + + if (selectedIndex >= 0 && links[selectedIndex]) { + links[selectedIndex].classList.add(SELECTED_CLASS); + } + + return links; + } + + scrollToSelected(wrapperElement) { + // This is a more imperative approach that's meant to be compatible with the pre-existing autocomplete templates, + // we should refactor in future to use component templates that are more declarative in setting the `selected` class. + + if (!wrapperElement) { + return; + } + // Find all links in the autocomplete menu and update selection + const links = this._applySelectedClass(wrapperElement, this.selectedIndex); + + if (!links || links.length === 0 || !links[this.selectedIndex]) { + return; + } + + links[this.selectedIndex].scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + + @action + handleInitialRender() { + this.args.data.onRender?.(this.results); + } + + @action + handleClick(event) { + if (!this.args.data.template) { + return; + } + + try { + event.preventDefault(); + event.stopPropagation(); + + const clickedLink = event.target.closest(RESULT_ITEM_SELECTOR); + if (!clickedLink) { + return; + } + + // Find the index of the clicked link + const links = event.currentTarget.querySelectorAll(RESULT_ITEM_SELECTOR); + const index = Array.from(links).indexOf(clickedLink); + + if (index >= 0) { + // Call onSelect and handle any promise returned + const result = this.args.data.onSelect( + this.results[index], + index, + event + ); + if (result && typeof result.then === "function") { + result.catch((e) => { + // eslint-disable-next-line no-console + console.error("[autocomplete] onSelect promise rejected: ", e); + }); + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.error("[autocomplete] Click handler error: ", e); + } + } + + @action + handleUpdate(wrapperElement) { + this.isInitialRender = false; + this.scrollToSelected(wrapperElement); + // Call onRender callback after DOM is ready + this.args.data.onRender?.(this.results); + } + + get templateHTML() { + if (!this.args.data.template) { + return ""; + } + + const template = this.args.data.template({ options: this.results }); + + if (!this.isInitialRender || this.selectedIndex < 0) { + return htmlSafe(template); + } + + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = template; + this._applySelectedClass(tempDiv, this.selectedIndex); + + return htmlSafe(tempDiv.innerHTML); + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/d-editor.gjs b/app/assets/javascripts/discourse/app/components/d-editor.gjs index 03f21e6256b6d..1012a034abe10 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.gjs +++ b/app/assets/javascripts/discourse/app/components/d-editor.gjs @@ -22,7 +22,6 @@ import EmojiPickerDetached from "discourse/components/emoji-picker/detached"; import UpsertHyperlink from "discourse/components/modal/upsert-hyperlink"; import PluginOutlet from "discourse/components/plugin-outlet"; import PopupInputTip from "discourse/components/popup-input-tip"; -import { SKIP } from "discourse/lib/autocomplete"; import renderEmojiAutocomplete from "discourse/lib/autocomplete/emoji"; import userAutocomplete from "discourse/lib/autocomplete/user"; import Toolbar from "discourse/lib/composer/toolbar"; @@ -44,6 +43,7 @@ import { initUserStatusHtml, renderUserStatusHtml, } from "discourse/lib/user-status-on-autocomplete"; +import { SKIP } from "discourse/modifiers/d-autocomplete"; import { i18n } from "discourse-i18n"; let _createCallbacks = []; @@ -94,9 +94,9 @@ export default class DEditor extends Component { this.setupToolbar(); - // TODO (martin) Remove this once we are sure all users have migrated - // to the new rich editor preference, or a few months after the 3.5 release. if (this.siteSettings.rich_editor) { + // TODO (martin) Remove this once we are sure all users have migrated + // to the new rich editor preference, or a few months after the 3.5 release. await this.handleOldRichEditorPreference(); if (this.currentUser.useRichEditor) { diff --git a/app/assets/javascripts/discourse/app/components/d-navigation.gjs b/app/assets/javascripts/discourse/app/components/d-navigation.gjs index 1689bfa6b1df7..632379031a1ca 100644 --- a/app/assets/javascripts/discourse/app/components/d-navigation.gjs +++ b/app/assets/javascripts/discourse/app/components/d-navigation.gjs @@ -4,6 +4,7 @@ import { hash } from "@ember/helper"; import { action } from "@ember/object"; import { dependentKeyCompat } from "@ember/object/compat"; import { service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; import { tagName } from "@ember-decorators/component"; import { and, gt } from "truth-helpers"; import BreadCrumbs from "discourse/components/bread-crumbs"; @@ -116,6 +117,35 @@ export default class DNavigation extends Component { } } + @discourseComputed( + "createTopicDisabled", + "categoryReadOnlyBanner", + "canCreateTopicOnTag", + "tag.id" + ) + createTopicButtonDisabled( + createTopicDisabled, + categoryReadOnlyBanner, + canCreateTopicOnTag, + tagId + ) { + if (tagId && !canCreateTopicOnTag) { + return true; + } else if (categoryReadOnlyBanner) { + return false; + } + return createTopicDisabled; + } + + @discourseComputed("categoryReadOnlyBanner") + createTopicClass(categoryReadOnlyBanner) { + let classNames = ["btn-default"]; + if (categoryReadOnlyBanner) { + classNames.push("disabled"); + } + return classNames.join(" "); + } + @discourseComputed("category.can_edit") showCategoryEdit(canEdit) { return canEdit; @@ -195,7 +225,11 @@ export default class DNavigation extends Component { @action clickCreateTopicButton() { - this.createTopic(); + if (this.categoryReadOnlyBanner) { + this.dialog.alert({ message: htmlSafe(this.categoryReadOnlyBanner) }); + } else { + this.createTopic(); + } }