diff --git a/app/assets/javascripts/admin/addon/components/ace-editor.js b/app/assets/javascripts/admin/addon/components/ace-editor.js index 58c1d0b8aa8..06ed3763031 100644 --- a/app/assets/javascripts/admin/addon/components/ace-editor.js +++ b/app/assets/javascripts/admin/addon/components/ace-editor.js @@ -156,7 +156,9 @@ export default Component.extend({ }, willDestroyElement() { - this._darkModeListener.removeListener(this.setAceTheme); + if (this._darkModeListener) { + this._darkModeListener.removeListener(this.setAceTheme); + } }, @bind diff --git a/app/assets/javascripts/admin/addon/controllers/admin-api-keys.js b/app/assets/javascripts/admin/addon/controllers/admin-api-keys.js index e69de29bb2d..7ae8f5a1e2e 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-api-keys.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-api-keys.js @@ -0,0 +1,3 @@ +import Controller from "@ember/controller"; + +export default Controller.extend(); diff --git a/app/assets/javascripts/discourse-common/addon/lib/legacy-resolver.js b/app/assets/javascripts/discourse-common/addon/lib/legacy-resolver.js new file mode 100644 index 00000000000..b7d66de8a27 --- /dev/null +++ b/app/assets/javascripts/discourse-common/addon/lib/legacy-resolver.js @@ -0,0 +1,336 @@ +/* global Ember */ +import { classify, dasherize, decamelize } from "@ember/string"; +import deprecated from "discourse-common/lib/deprecated"; +import { findHelper } from "discourse-common/lib/helpers"; +import { get } from "@ember/object"; +import SuffixTrie from "discourse-common/lib/suffix-trie"; + +let _options = {}; +let moduleSuffixTrie = null; + +export function setResolverOption(name, value) { + _options[name] = value; +} + +export function getResolverOption(name) { + return _options[name]; +} + +export function clearResolverOptions() { + _options = {}; +} + +function parseName(fullName) { + const nameParts = fullName.split(":"); + const type = nameParts[0]; + let fullNameWithoutType = nameParts[1]; + const namespace = get(this, "namespace"); + const root = namespace; + + return { + fullName, + type, + fullNameWithoutType, + name: fullNameWithoutType, + root, + resolveMethodName: "resolve" + classify(type), + }; +} + +function lookupModuleBySuffix(suffix) { + if (!moduleSuffixTrie) { + moduleSuffixTrie = new SuffixTrie("/"); + Object.keys(requirejs.entries).forEach((name) => { + if (!name.includes("/templates/")) { + moduleSuffixTrie.add(name); + } + }); + } + return moduleSuffixTrie.withSuffix(suffix, 1)[0]; +} + +export function buildResolver(baseName) { + return Ember.DefaultResolver.extend({ + parseName, + + resolveRouter(parsedName) { + const routerPath = `${baseName}/router`; + if (requirejs.entries[routerPath]) { + const module = requirejs(routerPath, null, null, true); + return module.default; + } + return this._super(parsedName); + }, + + normalize(fullName) { + if (fullName === "app-events:main") { + deprecated( + "`app-events:main` has been replaced with `service:app-events`", + { since: "2.4.0", dropFrom: "2.9.0.beta1" } + ); + return "service:app-events"; + } + + for (const [key, value] of Object.entries({ + "controller:discovery.categoryWithID": "controller:discovery.category", + "controller:discovery.parentCategory": "controller:discovery.category", + "controller:tags-show": "controller:tag-show", + "controller:tags.show": "controller:tag.show", + "controller:tagsShow": "controller:tagShow", + "route:discovery.categoryWithID": "route:discovery.category", + "route:discovery.parentCategory": "route:discovery.category", + "route:tags-show": "route:tag-show", + "route:tags.show": "route:tag.show", + "route:tagsShow": "route:tagShow", + })) { + if (fullName === key) { + deprecated(`${key} was replaced with ${value}`, { since: "2.6.0" }); + return value; + } + } + + const split = fullName.split(":"); + if (split.length > 1) { + const appBase = `${baseName}/${split[0]}s/`; + const adminBase = "admin/" + split[0] + "s/"; + const wizardBase = "wizard/" + split[0] + "s/"; + + // Allow render 'admin/templates/xyz' too + split[1] = split[1].replace(".templates", "").replace("/templates", ""); + + // Try slashes + let dashed = dasherize(split[1].replace(/\./g, "/")); + if ( + requirejs.entries[appBase + dashed] || + requirejs.entries[adminBase + dashed] || + requirejs.entries[wizardBase + dashed] + ) { + return split[0] + ":" + dashed; + } + + // Try with dashes instead of slashes + dashed = dasherize(split[1].replace(/\./g, "-")); + if ( + requirejs.entries[appBase + dashed] || + requirejs.entries[adminBase + dashed] || + requirejs.entries[wizardBase + dashed] + ) { + return split[0] + ":" + dashed; + } + } + return this._super(fullName); + }, + + customResolve(parsedName) { + // If we end with the name we want, use it. This allows us to define components within plugins. + const suffix = parsedName.type + "s/" + parsedName.fullNameWithoutType, + dashed = dasherize(suffix), + moduleName = lookupModuleBySuffix(dashed); + + let module; + if (moduleName) { + module = requirejs(moduleName, null, null, true /* force sync */); + if (module && module["default"]) { + module = module["default"]; + } + } + return module; + }, + + resolveWidget(parsedName) { + return this.customResolve(parsedName) || this._super(parsedName); + }, + + resolveAdapter(parsedName) { + return this.customResolve(parsedName) || this._super(parsedName); + }, + + resolveModel(parsedName) { + return this.customResolve(parsedName) || this._super(parsedName); + }, + + resolveView(parsedName) { + return this.customResolve(parsedName) || this._super(parsedName); + }, + + resolveHelper(parsedName) { + return ( + findHelper(parsedName.fullNameWithoutType) || + this.customResolve(parsedName) || + this._super(parsedName) + ); + }, + + resolveController(parsedName) { + return this.customResolve(parsedName) || this._super(parsedName); + }, + + resolveComponent(parsedName) { + return this.customResolve(parsedName) || this._super(parsedName); + }, + + resolveService(parsedName) { + return this.customResolve(parsedName) || this._super(parsedName); + }, + + resolveRawView(parsedName) { + return this.customResolve(parsedName) || this._super(parsedName); + }, + + resolveRoute(parsedName) { + if (parsedName.fullNameWithoutType === "basic") { + return requirejs("discourse/routes/discourse", null, null, true) + .default; + } + + return this.customResolve(parsedName) || this._super(parsedName); + }, + + findLoadingTemplate(parsedName) { + if (parsedName.fullNameWithoutType.match(/loading$/)) { + return Ember.TEMPLATES.loading; + } + }, + + findConnectorTemplate(parsedName) { + const full = parsedName.fullNameWithoutType.replace("components/", ""); + if (full.indexOf("connectors") === 0) { + return Ember.TEMPLATES[`javascripts/${full}`]; + } + }, + + resolveTemplate(parsedName) { + return ( + this.findPluginMobileTemplate(parsedName) || + this.findPluginTemplate(parsedName) || + this.findMobileTemplate(parsedName) || + this.findTemplate(parsedName) || + this.findLoadingTemplate(parsedName) || + this.findConnectorTemplate(parsedName) || + Ember.TEMPLATES.not_found + ); + }, + + findPluginTemplate(parsedName) { + const pluginParsedName = this.parseName( + parsedName.fullName.replace("template:", "template:javascripts/") + ); + return this.findTemplate(pluginParsedName); + }, + + findPluginMobileTemplate(parsedName) { + if (_options.mobileView) { + let pluginParsedName = this.parseName( + parsedName.fullName.replace( + "template:", + "template:javascripts/mobile/" + ) + ); + return this.findTemplate(pluginParsedName); + } + }, + + findMobileTemplate(parsedName) { + if (_options.mobileView) { + let mobileParsedName = this.parseName( + parsedName.fullName.replace("template:", "template:mobile/") + ); + return this.findTemplate(mobileParsedName); + } + }, + + findTemplate(parsedName) { + const withoutType = parsedName.fullNameWithoutType, + slashedType = withoutType.replace(/\./g, "/"), + decamelized = decamelize(withoutType), + dashed = decamelized.replace(/\./g, "-").replace(/\_/g, "-"), + templates = Ember.TEMPLATES; + + return ( + this._super(parsedName) || + templates[slashedType] || + templates[withoutType] || + templates[withoutType.replace(/\.raw$/, "")] || + templates[dashed] || + templates[decamelized.replace(/\./, "/")] || + templates[decamelized.replace(/\_/, "/")] || + templates[`${baseName}/templates/${withoutType}`] || + this.findAdminTemplate(parsedName) || + this.findWizardTemplate(parsedName) || + this.findUnderscoredTemplate(parsedName) + ); + }, + + findUnderscoredTemplate(parsedName) { + let decamelized = decamelize(parsedName.fullNameWithoutType); + let underscored = decamelized.replace(/\-/g, "_"); + return Ember.TEMPLATES[underscored]; + }, + + // Try to find a template within a special admin namespace, e.g. adminEmail => admin/templates/email + // (similar to how discourse lays out templates) + findAdminTemplate(parsedName) { + let decamelized = decamelize(parsedName.fullNameWithoutType); + if (decamelized.indexOf("components") === 0) { + let comPath = `admin/templates/${decamelized}`; + const compTemplate = + Ember.TEMPLATES[`javascripts/${comPath}`] || Ember.TEMPLATES[comPath]; + if (compTemplate) { + return compTemplate; + } + } + + if (decamelized === "javascripts/admin") { + return Ember.TEMPLATES["admin/templates/admin"]; + } + + if ( + decamelized.indexOf("admin") === 0 || + decamelized.indexOf("javascripts/admin") === 0 + ) { + decamelized = decamelized.replace(/^admin\_/, "admin/templates/"); + decamelized = decamelized.replace(/^admin\./, "admin/templates/"); + decamelized = decamelized.replace(/\./g, "_"); + + const dashed = decamelized.replace(/_/g, "-"); + return ( + Ember.TEMPLATES[decamelized] || + Ember.TEMPLATES[dashed] || + Ember.TEMPLATES[dashed.replace("admin-", "admin/")] + ); + } + }, + + findWizardTemplate(parsedName) { + let decamelized = decamelize(parsedName.fullNameWithoutType); + if (decamelized.startsWith("components")) { + let comPath = `wizard/templates/${decamelized}`; + const compTemplate = + Ember.TEMPLATES[`javascripts/${comPath}`] || Ember.TEMPLATES[comPath]; + if (compTemplate) { + return compTemplate; + } + } + + if (decamelized === "javascripts/wizard") { + return Ember.TEMPLATES["wizard/templates/wizard"]; + } + + if ( + decamelized.startsWith("wizard") || + decamelized.startsWith("javascripts/wizard") + ) { + decamelized = decamelized.replace(/^wizard\_/, "wizard/templates/"); + decamelized = decamelized.replace(/^wizard\./, "wizard/templates/"); + decamelized = decamelized.replace(/\./g, "_"); + + const dashed = decamelized.replace(/_/g, "-"); + return ( + Ember.TEMPLATES[decamelized] || + Ember.TEMPLATES[dashed] || + Ember.TEMPLATES[dashed.replace("wizard-", "wizard/")] + ); + } + }, + }); +} diff --git a/app/assets/javascripts/discourse-common/addon/resolver.js b/app/assets/javascripts/discourse-common/addon/resolver.js index c0b8162866a..913353103e9 100644 --- a/app/assets/javascripts/discourse-common/addon/resolver.js +++ b/app/assets/javascripts/discourse-common/addon/resolver.js @@ -1,9 +1,10 @@ -/* eslint-disable no-undef */ -import { classify, dasherize, decamelize } from "@ember/string"; +/* global Ember */ +import { dasherize, decamelize } from "@ember/string"; import deprecated from "discourse-common/lib/deprecated"; import { findHelper } from "discourse-common/lib/helpers"; -import { get } from "@ember/object"; import SuffixTrie from "discourse-common/lib/suffix-trie"; +import Resolver from "ember-resolver"; +import { buildResolver as buildLegacyResolver } from "discourse-common/lib/legacy-resolver"; let _options = {}; let moduleSuffixTrie = null; @@ -20,23 +21,6 @@ export function clearResolverOptions() { _options = {}; } -function parseName(fullName) { - const nameParts = fullName.split(":"); - const type = nameParts[0]; - let fullNameWithoutType = nameParts[1]; - const namespace = get(this, "namespace"); - const root = namespace; - - return { - fullName, - type, - fullNameWithoutType, - name: fullNameWithoutType, - root, - resolveMethodName: "resolve" + classify(type), - }; -} - function lookupModuleBySuffix(suffix) { if (!moduleSuffixTrie) { moduleSuffixTrie = new SuffixTrie("/"); @@ -50,25 +34,32 @@ function lookupModuleBySuffix(suffix) { } export function buildResolver(baseName) { - return Ember.DefaultResolver.extend({ - parseName, + let LegacyResolver = buildLegacyResolver(baseName); - resolveRouter(parsedName) { + return class extends Resolver { + LegacyResolver = LegacyResolver; + + init(props) { + super.init(props); + this.legacyResolver = this.LegacyResolver.create(props); + } + + resolveRouter(/* parsedName */) { const routerPath = `${baseName}/router`; if (requirejs.entries[routerPath]) { const module = requirejs(routerPath, null, null, true); return module.default; } - return this._super(parsedName); - }, + } - normalize(fullName) { + // We overwrite this instead of `normalize` so we still get the benefits of the cache. + _normalize(fullName) { if (fullName === "app-events:main") { deprecated( "`app-events:main` has been replaced with `service:app-events`", { since: "2.4.0", dropFrom: "2.9.0.beta1" } ); - return "service:app-events"; + fullName = "service:app-events"; } for (const [key, value] of Object.entries({ @@ -85,119 +76,110 @@ export function buildResolver(baseName) { })) { if (fullName === key) { deprecated(`${key} was replaced with ${value}`, { since: "2.6.0" }); - return value; + fullName = value; } } + let normalized = super._normalize(fullName); + + // This is code that we don't really want to keep long term. The main situation where we need it is for + // doing stuff like `controllerFor('adminWatchedWordsAction')` where the real route name + // is actually `adminWatchedWords.action`. The default behavior for the former is to + // normalize to `adminWatchedWordsAction` where the latter becomes `adminWatchedWords.action`. + // While these end up looking up the same file ultimately, they are treated as different + // items and so we can end up with two distinct version of the controller! const split = fullName.split(":"); - if (split.length > 1) { - const appBase = `${baseName}/${split[0]}s/`; - const adminBase = "admin/" + split[0] + "s/"; - const wizardBase = "wizard/" + split[0] + "s/"; + const type = split[0]; + if ( + split.length > 1 && + (type === "controller" || type === "route" || type === "template") + ) { + let corrected; + // This should only apply when there's a dot or slash in the name + if (split[1].includes(".") || split[1].includes("/")) { + // Check to see if the dasherized version exists. If it does we want to + // normalize to that eagerly so the normalized versions of the dotted/slashed and + // dotless/slashless match. + const dashed = dasherize(split[1].replace(/[\.\/]/g, "-")); - // Allow render 'admin/templates/xyz' too - split[1] = split[1].replace(".templates", "").replace("/templates", ""); - - // Try slashes - let dashed = dasherize(split[1].replace(/\./g, "/")); - if ( - requirejs.entries[appBase + dashed] || - requirejs.entries[adminBase + dashed] || - requirejs.entries[wizardBase + dashed] - ) { - return split[0] + ":" + dashed; + const adminBase = `admin/${type}s/`; + const wizardBase = `wizard/${type}s/`; + if ( + lookupModuleBySuffix(`${type}s/${dashed}`) || + requirejs.entries[adminBase + dashed] || + requirejs.entries[adminBase + dashed.replace(/^admin[-]/, "")] || + requirejs.entries[ + adminBase + dashed.replace(/^admin[-]/, "").replace(/-/g, "_") + ] || + requirejs.entries[wizardBase + dashed] || + requirejs.entries[wizardBase + dashed.replace(/^wizard[-]/, "")] || + requirejs.entries[ + wizardBase + dashed.replace(/^wizard[-]/, "").replace(/-/g, "_") + ] + ) { + corrected = type + ":" + dashed; + } } - // Try with dashes instead of slashes - dashed = dasherize(split[1].replace(/\./g, "-")); - if ( - requirejs.entries[appBase + dashed] || - requirejs.entries[adminBase + dashed] || - requirejs.entries[wizardBase + dashed] - ) { - return split[0] + ":" + dashed; + if (corrected && corrected !== normalized) { + normalized = corrected; } } - return this._super(fullName); - }, - customResolve(parsedName) { - // If we end with the name we want, use it. This allows us to define components within plugins. - const suffix = parsedName.type + "s/" + parsedName.fullNameWithoutType, - dashed = dasherize(suffix), - moduleName = lookupModuleBySuffix(dashed); + return normalized; + } - let module; - if (moduleName) { - module = requirejs(moduleName, null, null, true /* force sync */); - if (module && module["default"]) { - module = module["default"]; + chooseModuleName(moduleName, parsedName) { + let resolved = super.chooseModuleName(moduleName, parsedName); + if (resolved) { + return resolved; + } + + const standard = parsedName.fullNameWithoutType; + + let variants = [standard]; + + if (standard.includes("/")) { + variants.push(parsedName.fullNameWithoutType.replace(/\//g, "-")); + } + + for (let name of variants) { + // If we end with the name we want, use it. This allows us to define components within plugins. + const suffix = parsedName.type + "s/" + name; + resolved = lookupModuleBySuffix(dasherize(suffix)); + if (resolved) { + return resolved; } } - return module; - }, + } - resolveWidget(parsedName) { - return this.customResolve(parsedName) || this._super(parsedName); - }, - - resolveAdapter(parsedName) { - return this.customResolve(parsedName) || this._super(parsedName); - }, - - resolveModel(parsedName) { - return this.customResolve(parsedName) || this._super(parsedName); - }, - - resolveView(parsedName) { - return this.customResolve(parsedName) || this._super(parsedName); - }, + resolveOther(parsedName) { + let resolved = super.resolveOther(parsedName); + if (!resolved) { + let legacyParsedName = this.legacyResolver.parseName( + `${parsedName.type}:${parsedName.fullName}` + ); + resolved = this.legacyResolver.resolveOther(legacyParsedName); + if (resolved) { + deprecated( + `Unable to resolve with new resolver, but resolved with legacy resolver: ${parsedName.fullName}` + ); + } + } + return resolved; + } resolveHelper(parsedName) { - return ( - findHelper(parsedName.fullNameWithoutType) || - this.customResolve(parsedName) || - this._super(parsedName) - ); - }, - - resolveController(parsedName) { - return this.customResolve(parsedName) || this._super(parsedName); - }, - - resolveComponent(parsedName) { - return this.customResolve(parsedName) || this._super(parsedName); - }, - - resolveService(parsedName) { - return this.customResolve(parsedName) || this._super(parsedName); - }, - - resolveRawView(parsedName) { - return this.customResolve(parsedName) || this._super(parsedName); - }, + return findHelper(parsedName.fullNameWithoutType); + } + // If no match is found here, the resolver falls back to `resolveOther`. resolveRoute(parsedName) { if (parsedName.fullNameWithoutType === "basic") { return requirejs("discourse/routes/discourse", null, null, true) .default; } - - return this.customResolve(parsedName) || this._super(parsedName); - }, - - findLoadingTemplate(parsedName) { - if (parsedName.fullNameWithoutType.match(/loading$/)) { - return Ember.TEMPLATES.loading; - } - }, - - findConnectorTemplate(parsedName) { - const full = parsedName.fullNameWithoutType.replace("components/", ""); - if (full.indexOf("connectors") === 0) { - return Ember.TEMPLATES[`javascripts/${full}`]; - } - }, + } resolveTemplate(parsedName) { return ( @@ -205,132 +187,135 @@ export function buildResolver(baseName) { this.findPluginTemplate(parsedName) || this.findMobileTemplate(parsedName) || this.findTemplate(parsedName) || + this.findAdminTemplate(parsedName) || + this.findWizardTemplate(parsedName) || this.findLoadingTemplate(parsedName) || this.findConnectorTemplate(parsedName) || Ember.TEMPLATES.not_found ); - }, + } + + findLoadingTemplate(parsedName) { + if (parsedName.fullNameWithoutType.match(/loading$/)) { + return Ember.TEMPLATES.loading; + } + } + + findConnectorTemplate(parsedName) { + if (parsedName.fullName.startsWith("template:connectors/")) { + const connectorParsedName = this.parseName( + parsedName.fullName + .replace("template:connectors/", "template:") + .replace("components/", "") + ); + return this.findTemplate(connectorParsedName, "javascripts/"); + } + } findPluginTemplate(parsedName) { - const pluginParsedName = this.parseName( - parsedName.fullName.replace("template:", "template:javascripts/") - ); - return this.findTemplate(pluginParsedName); - }, + return this.findTemplate(parsedName, "javascripts/"); + } findPluginMobileTemplate(parsedName) { if (_options.mobileView) { - let pluginParsedName = this.parseName( - parsedName.fullName.replace( - "template:", - "template:javascripts/mobile/" - ) - ); - return this.findTemplate(pluginParsedName); + return this.findTemplate(parsedName, "javascripts/mobile/"); } - }, + } findMobileTemplate(parsedName) { if (_options.mobileView) { - let mobileParsedName = this.parseName( - parsedName.fullName.replace("template:", "template:mobile/") - ); - return this.findTemplate(mobileParsedName); + return this.findTemplate(parsedName, "mobile/"); } - }, + } + + findTemplate(parsedName, prefix) { + prefix = prefix || ""; - findTemplate(parsedName) { const withoutType = parsedName.fullNameWithoutType, - slashedType = withoutType.replace(/\./g, "/"), - decamelized = decamelize(withoutType), - dashed = decamelized.replace(/\./g, "-").replace(/\_/g, "-"), + underscored = decamelize(withoutType).replace(/-/g, "_"), + segments = withoutType.split("/"), templates = Ember.TEMPLATES; return ( - this._super(parsedName) || - templates[slashedType] || - templates[withoutType] || - templates[withoutType.replace(/\.raw$/, "")] || - templates[dashed] || - templates[decamelized.replace(/\./, "/")] || - templates[decamelized.replace(/\_/, "/")] || - templates[`${baseName}/templates/${withoutType}`] || - this.findAdminTemplate(parsedName) || - this.findWizardTemplate(parsedName) || - this.findUnderscoredTemplate(parsedName) + // Convert dots and dashes to slashes + templates[prefix + withoutType.replace(/[\.-]/g, "/")] || + // Default unmodified behavior of original resolveTemplate. + templates[prefix + withoutType] || + // Underscored without namespace + templates[prefix + underscored] || + // Underscored with first segment as directory + templates[prefix + underscored.replace("_", "/")] || + // Underscore only the last segment + templates[ + `${prefix}${segments.slice(0, -1).join("/")}/${segments[ + segments.length - 1 + ].replace(/-/g, "_")}` + ] || + // All dasherized + templates[prefix + withoutType.replace(/\//g, "-")] ); - }, - - findUnderscoredTemplate(parsedName) { - let decamelized = decamelize(parsedName.fullNameWithoutType); - let underscored = decamelized.replace(/\-/g, "_"); - return Ember.TEMPLATES[underscored]; - }, + } // Try to find a template within a special admin namespace, e.g. adminEmail => admin/templates/email // (similar to how discourse lays out templates) findAdminTemplate(parsedName) { - let decamelized = decamelize(parsedName.fullNameWithoutType); - if (decamelized.indexOf("components") === 0) { - let comPath = `admin/templates/${decamelized}`; - const compTemplate = - Ember.TEMPLATES[`javascripts/${comPath}`] || Ember.TEMPLATES[comPath]; - if (compTemplate) { - return compTemplate; - } - } - - if (decamelized === "javascripts/admin") { + if (parsedName.fullNameWithoutType === "admin") { return Ember.TEMPLATES["admin/templates/admin"]; } - if ( - decamelized.indexOf("admin") === 0 || - decamelized.indexOf("javascripts/admin") === 0 - ) { - decamelized = decamelized.replace(/^admin\_/, "admin/templates/"); - decamelized = decamelized.replace(/^admin\./, "admin/templates/"); - decamelized = decamelized.replace(/\./g, "_"); + let namespaced, match; - const dashed = decamelized.replace(/_/g, "-"); - return ( - Ember.TEMPLATES[decamelized] || - Ember.TEMPLATES[dashed] || - Ember.TEMPLATES[dashed.replace("admin-", "admin/")] - ); + if (parsedName.fullNameWithoutType.startsWith("components/")) { + // Look up components as-is + } else if (/^admin[_\.-]/.test(parsedName.fullNameWithoutType)) { + namespaced = parsedName.fullNameWithoutType.slice(6); + } else if ( + (match = parsedName.fullNameWithoutType.match(/^admin([A-Z])(.+)$/)) + ) { + namespaced = `${match[1].toLowerCase()}${match[2]}`; } - }, + + let resolved; + + if (namespaced) { + let adminParsedName = this.parseName(`template:${namespaced}`); + resolved = + // Built-in + this.findTemplate(adminParsedName, "admin/templates/") || + // Plugin + this.findTemplate(adminParsedName, "javascripts/admin/"); + } + + resolved ??= + // Built-in + this.findTemplate(parsedName, "admin/templates/") || + // Plugin + this.findTemplate(parsedName, "javascripts/admin/"); + + return resolved; + } findWizardTemplate(parsedName) { - let decamelized = decamelize(parsedName.fullNameWithoutType); - if (decamelized.startsWith("components")) { - let comPath = `wizard/templates/${decamelized}`; - const compTemplate = - Ember.TEMPLATES[`javascripts/${comPath}`] || Ember.TEMPLATES[comPath]; - if (compTemplate) { - return compTemplate; - } - } - - if (decamelized === "javascripts/wizard") { + if (parsedName.fullNameWithoutType === "wizard") { return Ember.TEMPLATES["wizard/templates/wizard"]; } - if ( - decamelized.startsWith("wizard") || - decamelized.startsWith("javascripts/wizard") - ) { - decamelized = decamelized.replace(/^wizard\_/, "wizard/templates/"); - decamelized = decamelized.replace(/^wizard\./, "wizard/templates/"); - decamelized = decamelized.replace(/\./g, "_"); + let namespaced; - const dashed = decamelized.replace(/_/g, "-"); - return ( - Ember.TEMPLATES[decamelized] || - Ember.TEMPLATES[dashed] || - Ember.TEMPLATES[dashed.replace("wizard-", "wizard/")] - ); + if (parsedName.fullNameWithoutType.startsWith("components/")) { + // Look up components as-is + namespaced = parsedName.fullNameWithoutType; + } else if (/^wizard[_\.-]/.test(parsedName.fullNameWithoutType)) { + // This may only get hit for the loading routes and may be removable. + namespaced = parsedName.fullNameWithoutType.slice(7); } - }, - }); + + if (namespaced) { + let adminParsedName = this.parseName( + `template:wizard/templates/${namespaced}` + ); + return this.findTemplate(adminParsedName); + } + } + }; } diff --git a/app/assets/javascripts/discourse-common/package.json b/app/assets/javascripts/discourse-common/package.json index 6761055ed9c..c28b3ce3c93 100644 --- a/app/assets/javascripts/discourse-common/package.json +++ b/app/assets/javascripts/discourse-common/package.json @@ -24,6 +24,7 @@ "ember-auto-import": "^2.4.2", "ember-cli-babel": "^7.23.1", "ember-cli-htmlbars": "^6.0.1", + "ember-resolver": "^8.0.3", "handlebars": "^4.7.0", "truth-helpers": "^1.0.0", "webpack": "^5.73.0" diff --git a/app/assets/javascripts/discourse/app/app.js b/app/assets/javascripts/discourse/app/app.js index a7df43a1715..8d5d9d8ea3a 100644 --- a/app/assets/javascripts/discourse/app/app.js +++ b/app/assets/javascripts/discourse/app/app.js @@ -6,6 +6,8 @@ const _pluginCallbacks = []; let _unhandledThemeErrors = []; const Discourse = Application.extend({ + modulePrefix: "discourse", + rootElement: "#main", customEvents: { diff --git a/app/assets/javascripts/discourse/app/pre-initializers/dynamic-route-builders.js b/app/assets/javascripts/discourse/app/pre-initializers/dynamic-route-builders.js index c0fa1b93288..60f7e8bbfb3 100644 --- a/app/assets/javascripts/discourse/app/pre-initializers/dynamic-route-builders.js +++ b/app/assets/javascripts/discourse/app/pre-initializers/dynamic-route-builders.js @@ -4,73 +4,120 @@ import TagShowRoute from "discourse/routes/tag-show"; import User from "discourse/models/user"; import buildCategoryRoute from "discourse/routes/build-category-route"; import buildTopicRoute from "discourse/routes/build-topic-route"; -import { capitalize } from "@ember/string"; export default { after: "inject-discourse-objects", name: "dynamic-route-builders", initialize(registry, app) { - app.DiscoveryCategoryController = DiscoverySortableController.extend(); - app.DiscoveryCategoryNoneController = DiscoverySortableController.extend(); - app.DiscoveryCategoryAllController = DiscoverySortableController.extend(); + app.register( + "controller:discovery.category", + DiscoverySortableController.extend() + ); + app.register( + "controller:discovery.category-none", + DiscoverySortableController.extend() + ); + app.register( + "controller:discovery.category-all", + DiscoverySortableController.extend() + ); - app.DiscoveryCategoryRoute = buildCategoryRoute("default"); - app.DiscoveryCategoryNoneRoute = buildCategoryRoute("default", { - no_subcategories: true, - }); - app.DiscoveryCategoryAllRoute = buildCategoryRoute("default", { - no_subcategories: false, - }); + app.register("route:discovery.category", buildCategoryRoute("default")); + app.register( + "route:discovery.category-none", + buildCategoryRoute("default", { + no_subcategories: true, + }) + ); + app.register( + "route:discovery.category-all", + buildCategoryRoute("default", { + no_subcategories: false, + }) + ); const site = Site.current(); site.get("filters").forEach((filter) => { - const filterCapitalized = capitalize(filter); - app[`Discovery${filterCapitalized}Controller`] = - DiscoverySortableController.extend(); - app[`Discovery${filterCapitalized}CategoryController`] = - DiscoverySortableController.extend(); - app[`Discovery${filterCapitalized}CategoryNoneController`] = - DiscoverySortableController.extend(); + const filterDasherized = filter.dasherize(); + app.register( + `controller:discovery.${filterDasherized}`, + DiscoverySortableController.extend() + ); + app.register( + `controller:discovery.${filterDasherized}-category`, + DiscoverySortableController.extend() + ); + app.register( + `controller:discovery.${filterDasherized}-category-none`, + DiscoverySortableController.extend() + ); if (filter === "top") { - app.DiscoveryTopRoute = buildTopicRoute("top", { - actions: { - willTransition() { - User.currentProp("should_be_redirected_to_top", false); - User.currentProp("redirected_to_top.reason", null); - return this._super(...arguments); + app.register( + "route:discovery.top", + buildTopicRoute("top", { + actions: { + willTransition() { + User.currentProp("should_be_redirected_to_top", false); + User.currentProp("redirected_to_top.reason", null); + return this._super(...arguments); + }, }, - }, - }); + }) + ); } else { - app[`Discovery${filterCapitalized}Route`] = buildTopicRoute(filter); + app.register( + `route:discovery.${filterDasherized}`, + buildTopicRoute(filter) + ); } - app[`Discovery${filterCapitalized}CategoryRoute`] = - buildCategoryRoute(filter); - app[`Discovery${filterCapitalized}CategoryNoneRoute`] = - buildCategoryRoute(filter, { no_subcategories: true }); + app.register( + `route:discovery.${filterDasherized}-category`, + buildCategoryRoute(filter) + ); + app.register( + `route:discovery.${filterDasherized}-category-none`, + buildCategoryRoute(filter, { no_subcategories: true }) + ); }); - app["TagsShowCategoryRoute"] = TagShowRoute.extend(); - app["TagsShowCategoryNoneRoute"] = TagShowRoute.extend({ - noSubcategories: true, - }); - app["TagsShowCategoryAllRoute"] = TagShowRoute.extend({ - noSubcategories: false, - }); + app.register("route:tags.show-category", TagShowRoute.extend()); + app.register( + "route:tags.show-category-none", + TagShowRoute.extend({ + noSubcategories: true, + }) + ); + app.register( + "route:tags.show-category-all", + TagShowRoute.extend({ + noSubcategories: false, + }) + ); site.get("filters").forEach(function (filter) { - app["TagShow" + capitalize(filter) + "Route"] = TagShowRoute.extend({ - navMode: filter, - }); - app["TagsShowCategory" + capitalize(filter) + "Route"] = - TagShowRoute.extend({ navMode: filter }); - app["TagsShowCategoryNone" + capitalize(filter) + "Route"] = - TagShowRoute.extend({ navMode: filter, noSubcategories: true }); - app["TagsShowCategoryAll" + capitalize(filter) + "Route"] = - TagShowRoute.extend({ navMode: filter, noSubcategories: false }); + const filterDasherized = filter.dasherize(); + + app.register( + `route:tag.show-${filterDasherized}`, + TagShowRoute.extend({ + navMode: filter, + }) + ); + app.register( + `route:tag.show-${filterDasherized}-category`, + TagShowRoute.extend({ navMode: filter }) + ); + app.register( + `route:tag.show-${filterDasherized}-category-none`, + TagShowRoute.extend({ navMode: filter, noSubcategories: true }) + ); + app.register( + `route:tag.show-${filterDasherized}-category-all`, + TagShowRoute.extend({ navMode: filter, noSubcategories: false }) + ); }); }, }; diff --git a/app/assets/javascripts/discourse/tests/helpers/create-store.js b/app/assets/javascripts/discourse/tests/helpers/create-store.js index 6933355dec4..f9ef5e5c8f2 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-store.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-store.js @@ -12,7 +12,9 @@ const CatAdapter = RestAdapter.extend({ }); export default function (customLookup = () => {}) { - const resolver = buildResolver("discourse").create(); + const resolver = buildResolver("discourse").create({ + namespace: { modulePrefix: "discourse" }, + }); // Normally this would happen in inject-discourse-objects. // However, `create-store` is used by unit tests which do not init the application. @@ -54,9 +56,10 @@ export default function (customLookup = () => {}) { lookupFactory(type) { const split = type.split(":"); - return resolver.customResolve({ + return resolver.resolveOther({ type: split[0], fullNameWithoutType: split[1], + root: {}, }); }, }, diff --git a/app/assets/javascripts/discourse/tests/unit/ember/resolver-test.js b/app/assets/javascripts/discourse/tests/unit/ember/resolver-test.js index fdcaff6a036..fb62e9d7342 100644 --- a/app/assets/javascripts/discourse/tests/unit/ember/resolver-test.js +++ b/app/assets/javascripts/discourse/tests/unit/ember/resolver-test.js @@ -26,7 +26,9 @@ module("Unit | Ember | resolver", function (hooks) { // eslint-disable-next-line no-undef Ember.TEMPLATES = {}; - resolver = DiscourseResolver.create(); + resolver = DiscourseResolver.create({ + namespace: { modulePrefix: "discourse" }, + }); }); hooks.afterEach(function () { @@ -37,38 +39,60 @@ module("Unit | Ember | resolver", function (hooks) { test("finds templates in top level dir", function (assert) { setTemplates(["foobar", "fooBar", "foo_bar", "foo.bar"]); + // Default unmodified behavior lookupTemplate(assert, "template:foobar", "foobar", "by lowcased name"); + + // Default unmodified behavior lookupTemplate(assert, "template:fooBar", "fooBar", "by camel cased name"); + + // Default unmodified behavior lookupTemplate( assert, "template:foo_bar", "foo_bar", "by underscored name" ); + + // Default unmodified behavior lookupTemplate(assert, "template:foo.bar", "foo.bar", "by dotted name"); }); test("finds templates in first-level subdir", function (assert) { setTemplates(["foo/bar_baz"]); + // Default unmodified behavior lookupTemplate( assert, "template:foo/bar_baz", "foo/bar_baz", "with subdir defined by slash" ); + + // Convert dots to slash lookupTemplate( assert, "template:foo.bar_baz", "foo/bar_baz", "with subdir defined by dot" ); + + // Convert dashes to slash + lookupTemplate( + assert, + "template:foo-bar_baz", + "foo/bar_baz", + "with subdir defined by dash" + ); + + // Underscored with first segment as directory lookupTemplate( assert, "template:fooBarBaz", "foo/bar_baz", "with subdir defined by first camel case and the rest of camel cases converted to underscores" ); + + // Already underscored with first segment as directory lookupTemplate( assert, "template:foo_bar_baz", @@ -78,49 +102,77 @@ module("Unit | Ember | resolver", function (hooks) { }); test("resolves precedence between overlapping top level dir and first level subdir templates", function (assert) { - setTemplates(["fooBar", "foo_bar", "foo.bar", "foo/bar"]); + setTemplates(["fooBar", "foo_bar", "foo.bar", "foo/bar", "baz/qux"]); + // Directories are prioritized when dotted lookupTemplate( assert, "template:foo.bar", "foo/bar", "preferring first level subdir for dotted name" ); + + // Directories are prioritized when dashed + lookupTemplate( + assert, + "template:foo-bar", + "foo/bar", + "preferring first level subdir for dotted name" + ); + + // Default unmodified before directories, except when dotted lookupTemplate( assert, "template:fooBar", "fooBar", "preferring top level dir for camel cased name" ); + + // Default unmodified before directories, except when dotted lookupTemplate( assert, "template:foo_bar", "foo_bar", "preferring top level dir for underscored name" ); + + // Use directory version if top-level isn't found + lookupTemplate( + assert, + "template:baz-qux", + "baz/qux", + "fallback subdir for dashed name" + ); }); test("finds templates in subdir deeper than one level", function (assert) { setTemplates(["foo/bar/baz/qux"]); + // Default unmodified lookupTemplate( assert, "template:foo/bar/baz/qux", "foo/bar/baz/qux", "for subdirs defined by slashes" ); + + // Converts dotted to slashed lookupTemplate( assert, "template:foo.bar.baz.qux", "foo/bar/baz/qux", "for subdirs defined by dots" ); + + // Converts first camelized segment to slashed lookupTemplate( assert, "template:foo/bar/bazQux", "foo/bar/baz/qux", "for subdirs defined by slashes plus one camel case" ); + + // Converts first underscore to slashed lookupTemplate( assert, "template:foo/bar/baz_qux", @@ -128,24 +180,31 @@ module("Unit | Ember | resolver", function (hooks) { "for subdirs defined by slashes plus one underscore" ); + // Only converts first camelized segment to slashed so this isn't matched lookupTemplate( assert, "template:fooBarBazQux", undefined, "but not for subdirs defined by more than one camel case" ); + + // Only converts first underscored segment to slashed so this isn't matched lookupTemplate( assert, "template:foo_bar_baz_qux", undefined, "but not for subdirs defined by more than one underscore" ); + + // Only converts dots to slashes OR first camelized segment. This has both so isn't matched. lookupTemplate( assert, "template:foo.bar.bazQux", undefined, "but not for subdirs defined by dots plus one camel case" ); + + // Only converts dots to slashes OR first underscored segment. This has both so isn't matched. lookupTemplate( assert, "template:foo.bar.baz_qux", @@ -159,18 +218,23 @@ module("Unit | Ember | resolver", function (hooks) { setResolverOption("mobileView", true); + // Default with mobile/ added lookupTemplate( assert, "template:foo", "mobile/foo", "finding mobile version even if normal one is not present" ); + + // Default with mobile preferred lookupTemplate( assert, "template:bar", "mobile/bar", "preferring mobile version when both mobile and normal versions are present" ); + + // Default when mobile not present lookupTemplate( assert, "template:baz", @@ -182,18 +246,23 @@ module("Unit | Ember | resolver", function (hooks) { test("resolves plugin templates to 'javascripts/' namespace", function (assert) { setTemplates(["javascripts/foo", "bar", "javascripts/bar", "baz"]); + // Default with javascripts/ added lookupTemplate( assert, "template:foo", "javascripts/foo", "finding plugin version even if normal one is not present" ); + + // Default with javascripts/ added, takes precedence lookupTemplate( assert, "template:bar", "javascripts/bar", "preferring plugin version when both versions are present" ); + + // Default when javascripts version not present lookupTemplate( assert, "template:baz", @@ -202,27 +271,71 @@ module("Unit | Ember | resolver", function (hooks) { ); }); - test("resolves templates with 'admin' prefix to 'admin/templates/' namespace", function (assert) { + test("resolves plugin mobile templates to 'javascripts/mobile/' namespace", function (assert) { + setTemplates([ + "javascripts/mobile/foo", + "javascripts/mobile/bar", + "javascripts/bar", + "javascripts/mobile/baz", + "mobile/baz", + ]); + + setResolverOption("mobileView", true); + + // Default with javascripts/mobile/ added + lookupTemplate( + assert, + "template:foo", + "javascripts/mobile/foo", + "finding plugin version even if normal one is not present" + ); + + // Default with javascripts/mobile added, takes precedence over non-mobile + lookupTemplate( + assert, + "template:bar", + "javascripts/mobile/bar", + "preferring plugin mobile version when both non-mobile plugin version is also present" + ); + + // Default with javascripts/mobile when non-plugin mobile version is present + lookupTemplate( + assert, + "template:baz", + "javascripts/mobile/baz", + "preferring plugin mobile version over non-plugin mobile version" + ); + }); + + test("resolves templates with 'admin' prefix", function (assert) { setTemplates([ "admin/templates/foo", "adminBar", "admin_bar", "admin.bar", "admin/templates/bar", + "admin/templates/dashboard_general", + "admin-baz-qux", + "javascripts/admin/plugin-template", ]); + // Switches prefix to admin/templates when camelized lookupTemplate( assert, "template:adminFoo", "admin/templates/foo", "when prefix is separated by camel case" ); + + // Switches prefix to admin/templates when underscored lookupTemplate( assert, "template:admin_foo", "admin/templates/foo", "when prefix is separated by underscore" ); + + // Switches prefix to admin/templates when dotted lookupTemplate( assert, "template:admin.foo", @@ -230,30 +343,165 @@ module("Unit | Ember | resolver", function (hooks) { "when prefix is separated by dot" ); + // Doesn't match unseparated prefix lookupTemplate( assert, "template:adminfoo", undefined, "but not when prefix is not separated in any way" ); + + // Prioritized the default match when camelized lookupTemplate( assert, "template:adminBar", "adminBar", "but not when template with the exact camel cased name exists" ); + + // Prioritized the default match when underscored lookupTemplate( assert, "template:admin_bar", "admin_bar", "but not when template with the exact underscored name exists" ); + + // Prioritized the default match when dotted lookupTemplate( assert, "template:admin.bar", "admin.bar", "but not when template with the exact dotted name exists" ); + + lookupTemplate( + assert, + "template:admin-dashboard-general", + "admin/templates/dashboard_general", + "finds namespaced and underscored version" + ); + + lookupTemplate( + assert, + "template:admin-baz/qux", + "admin-baz-qux", + "also tries dasherized" + ); + + lookupTemplate( + assert, + "template:admin-plugin/template", + "javascripts/admin/plugin-template", + "looks up templates in plugins" + ); + }); + + test("resolves component templates with 'admin' prefix to 'admin/templates/' namespace", function (assert) { + setTemplates([ + "admin/templates/components/foo", + "components/bar", + "admin/templates/components/bar", + ]); + + // Looks for components in admin/templates + lookupTemplate( + assert, + "template:components/foo", + "admin/templates/components/foo", + "uses admin template component when no standard match" + ); + + // Prioritized non-admin component + lookupTemplate( + assert, + "template:components/bar", + "components/bar", + "uses standard match when both exist" + ); + }); + + // We can probably remove this in the future since this behavior seems pretty + // close to Ember's default behavior. + // See https://guides.emberjs.com/release/routing/loading-and-error-substates/ + test("resolves loading templates", function (assert) { + setTemplates(["fooloading", "foo/loading", "foo_loading", "loading"]); + + lookupTemplate( + assert, + "template:fooloading", + "fooloading", + "exact match without separator" + ); + + lookupTemplate( + assert, + "template:foo/loading", + "foo/loading", + "exact match with slash" + ); + + lookupTemplate( + assert, + "template:foo_loading", + "foo_loading", + "exact match underscore" + ); + + lookupTemplate( + assert, + "template:barloading", + "loading", + "fallback without separator" + ); + + lookupTemplate( + assert, + "template:bar/loading", + "loading", + "fallback with slash" + ); + + lookupTemplate( + assert, + "template:bar.loading", + "loading", + "fallback with dot" + ); + + lookupTemplate( + assert, + "template:bar_loading", + "loading", + "fallback underscore" + ); + + // TODO: Maybe test precedence + }); + + test("resolves connector templates", function (assert) { + setTemplates(["javascripts/foo", "javascripts/connectors/foo-bar/baz_qux"]); + + lookupTemplate( + assert, + "template:connectors/foo", + "javascripts/foo", + "looks up in javascripts/ namespace" + ); + + lookupTemplate( + assert, + "template:connectors/components/foo", + "javascripts/foo", + "removes components segment" + ); + + lookupTemplate( + assert, + "template:connectors/foo-bar/baz-qux", + "javascripts/connectors/foo-bar/baz_qux", + "underscores last segment" + ); }); test("returns 'not_found' template when template name cannot be resolved", function (assert) { @@ -261,4 +509,94 @@ module("Unit | Ember | resolver", function (hooks) { lookupTemplate(assert, "template:foo/bar/baz", "not_found", ""); }); + + test("resolves templates with 'wizard' prefix", function (assert) { + setTemplates([ + "wizard/templates/foo", + "wizard_bar", + "wizard.bar", + "wizard/templates/bar", + "wizard/templates/dashboard_general", + "wizard-baz-qux", + "javascripts/wizard/plugin-template", + ]); + + // Switches prefix to wizard/templates when underscored + lookupTemplate( + assert, + "template:wizard_foo", + "wizard/templates/foo", + "when prefix is separated by underscore" + ); + + // Switches prefix to wizard/templates when dotted + lookupTemplate( + assert, + "template:wizard.foo", + "wizard/templates/foo", + "when prefix is separated by dot" + ); + + // Doesn't match unseparated prefix + lookupTemplate( + assert, + "template:wizardfoo", + undefined, + "but not when prefix is not separated in any way" + ); + + // Prioritized the default match when underscored + lookupTemplate( + assert, + "template:wizard_bar", + "wizard_bar", + "but not when template with the exact underscored name exists" + ); + + // Prioritized the default match when dotted + lookupTemplate( + assert, + "template:wizard.bar", + "wizard.bar", + "but not when template with the exact dotted name exists" + ); + + lookupTemplate( + assert, + "template:wizard-dashboard-general", + "wizard/templates/dashboard_general", + "finds namespaced and underscored version" + ); + + lookupTemplate( + assert, + "template:wizard-baz/qux", + "wizard-baz-qux", + "also tries dasherized" + ); + }); + + test("resolves component templates with 'wizard' prefix to 'wizard/templates/' namespace", function (assert) { + setTemplates([ + "wizard/templates/components/foo", + "components/bar", + "wizard/templates/components/bar", + ]); + + // Looks for components in wizard/templates + lookupTemplate( + assert, + "template:components/foo", + "wizard/templates/components/foo", + "uses wizard template component when no standard match" + ); + + // Prioritized non-wizard component + lookupTemplate( + assert, + "template:components/bar", + "components/bar", + "uses standard match when both exist" + ); + }); }); diff --git a/app/assets/javascripts/yarn.lock b/app/assets/javascripts/yarn.lock index f05e1f12fb4..7151743ed0d 100644 --- a/app/assets/javascripts/yarn.lock +++ b/app/assets/javascripts/yarn.lock @@ -4963,6 +4963,18 @@ ember-resolver@^7.0.0: ember-cli-version-checker "^3.1.3" resolve "^1.14.0" +ember-resolver@^8.0.3: + version "8.0.3" + resolved "https://registry.yarnpkg.com/ember-resolver/-/ember-resolver-8.0.3.tgz#40f243aa58281bf195c695fe84a6b291e204690a" + integrity sha512-fA53fxfG821BRqNiB9mQDuzZpzSRcSAYZTYBlRQOHsJwoYdjyE7idz4YcytbSsa409G5J2kP6B+PiKOBh0odlw== + dependencies: + babel-plugin-debug-macros "^0.3.4" + broccoli-funnel "^3.0.8" + broccoli-merge-trees "^4.2.0" + ember-cli-babel "^7.26.6" + ember-cli-version-checker "^5.1.2" + resolve "^1.20.0" + ember-rfc176-data@^0.3.17: version "0.3.17" resolved "https://registry.yarnpkg.com/ember-rfc176-data/-/ember-rfc176-data-0.3.17.tgz#d4fc6c33abd6ef7b3440c107a28e04417b49860a"