DEV: Support theme/plugin overrides of colocated component templates (#19237)

To theme/plugin developers, the process is the same as for overriding non-colocated component templates. Once merged, this should allow us to seamlessly convert all of core's component templates to be colocated.
This commit is contained in:
David Taylor 2022-11-30 14:14:38 +00:00 committed by GitHub
parent 105f500693
commit 9f022112e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 261 additions and 28 deletions

View File

@ -0,0 +1,58 @@
import DiscourseTemplateMap from "discourse-common/lib/discourse-template-map";
import * as GlimmerManager from "@glimmer/manager";
const COLOCATED_TEMPLATE_OVERRIDES = new Map();
// This patch is not ideal, but Ember does not allow us to change a component template after initial association
// https://github.com/glimmerjs/glimmer-vm/blob/03a4b55c03/packages/%40glimmer/manager/lib/public/template.ts#L14-L20
const originalGetTemplate = GlimmerManager.getComponentTemplate;
GlimmerManager.getComponentTemplate = (component) => {
return (
COLOCATED_TEMPLATE_OVERRIDES.get(component) ??
originalGetTemplate(component)
);
};
export default {
name: "colocated-template-overrides",
after: "populate-template-map",
initialize(container) {
this.eachThemePluginTemplate((templateKey, moduleNames) => {
if (!templateKey.startsWith("components/")) {
return;
}
if (DiscourseTemplateMap.coreTemplates.has(templateKey)) {
// It's a non-colocated core component. Template will be overridden at runtime.
return;
}
const componentName = templateKey.slice("components/".length);
const component = container.owner.resolveRegistration(
`component:${componentName}`
);
if (component && originalGetTemplate(component)) {
const finalOverrideModuleName = moduleNames[moduleNames.length - 1];
const overrideTemplate = require(finalOverrideModuleName).default;
COLOCATED_TEMPLATE_OVERRIDES.set(component, overrideTemplate);
}
});
},
eachThemePluginTemplate(cb) {
for (const [key, value] of DiscourseTemplateMap.pluginTemplates) {
cb(key, value);
}
for (const [key, value] of DiscourseTemplateMap.themeTemplates) {
cb(key, value);
}
},
teardown() {
COLOCATED_TEMPLATE_OVERRIDES.clear();
},
};

View File

@ -2,11 +2,11 @@ import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
import { hbs } from "ember-cli-htmlbars";
import { test } from "qunit";
import { visit } from "@ember/test-helpers";
import { registerTemplateModule } from "discourse/tests/helpers/template-module-helper";
import { registerTemporaryModule } from "discourse/tests/helpers/temporary-module-helper";
acceptance("CustomHTML template", function (needs) {
needs.hooks.beforeEach(() => {
registerTemplateModule(
registerTemporaryModule(
"discourse/templates/top",
hbs`<span class='top-span'>TOP</span>`
);

View File

@ -10,7 +10,7 @@ import { test } from "qunit";
import I18n from "I18n";
import { hbs } from "ember-cli-htmlbars";
import showModal from "discourse/lib/show-modal";
import { registerTemplateModule } from "../helpers/template-module-helper";
import { registerTemporaryModule } from "../helpers/temporary-module-helper";
acceptance("Modal", function (needs) {
let _translations;
@ -54,7 +54,7 @@ acceptance("Modal", function (needs) {
await triggerKeyEvent("#main-outlet", "keydown", "Escape");
assert.ok(!exists(".d-modal:visible"), "ESC should close the modal");
registerTemplateModule(
registerTemporaryModule(
"discourse/templates/modal/not-dismissable",
hbs`{{#d-modal-body title="" class="" dismissable=false}}test{{/d-modal-body}}`
);
@ -79,7 +79,7 @@ acceptance("Modal", function (needs) {
});
test("rawTitle in modal panels", async function (assert) {
registerTemplateModule(
registerTemporaryModule(
"discourse/templates/modal/test-raw-title-panels",
hbs``
);
@ -100,8 +100,8 @@ acceptance("Modal", function (needs) {
});
test("modal title", async function (assert) {
registerTemplateModule("discourse/templates/modal/test-title", hbs``);
registerTemplateModule(
registerTemporaryModule("discourse/templates/modal/test-title", hbs``);
registerTemporaryModule(
"discourse/templates/modal/test-title-with-body",
hbs`{{#d-modal-body}}test{{/d-modal-body}}`
);

View File

@ -9,7 +9,7 @@ import { action } from "@ember/object";
import { extraConnectorClass } from "discourse/lib/plugin-connectors";
import { hbs } from "ember-cli-htmlbars";
import { test } from "qunit";
import { registerTemplateModule } from "discourse/tests/helpers/template-module-helper";
import { registerTemporaryModule } from "discourse/tests/helpers/temporary-module-helper";
const PREFIX = "discourse/plugins/some-plugin/templates/connectors";
@ -49,19 +49,19 @@ acceptance("Plugin Outlet - Connector Class", function (needs) {
},
});
registerTemplateModule(
registerTemporaryModule(
`${PREFIX}/user-profile-primary/hello`,
hbs`<span class='hello-username'>{{model.username}}</span>
<button class='say-hello' {{on "click" (action "sayHello")}}></button>
<button class='say-hello-using-this' {{on "click" this.sayHello}}></button>
<span class='hello-result'>{{hello}}</span>`
);
registerTemplateModule(
registerTemporaryModule(
`${PREFIX}/user-profile-primary/hi`,
hbs`<button class='say-hi' {{on "click" (action "sayHi")}}></button>
<span class='hi-result'>{{hi}}</span>`
);
registerTemplateModule(
registerTemporaryModule(
`${PREFIX}/user-profile-primary/dont-render`,
hbs`I'm not rendered!`
);

View File

@ -7,7 +7,7 @@ import { hbs } from "ember-cli-htmlbars";
import { test } from "qunit";
import { visit } from "@ember/test-helpers";
import { withPluginApi } from "discourse/lib/plugin-api";
import { registerTemplateModule } from "../helpers/template-module-helper";
import { registerTemporaryModule } from "../helpers/temporary-module-helper";
const PREFIX = "discourse/plugins/some-plugin/templates/connectors";
@ -15,11 +15,11 @@ acceptance("Plugin Outlet - Decorator", function (needs) {
needs.user();
needs.hooks.beforeEach(() => {
registerTemplateModule(
registerTemporaryModule(
`${PREFIX}/discovery-list-container-top/foo`,
hbs`FOO`
);
registerTemplateModule(
registerTemporaryModule(
`${PREFIX}/discovery-list-container-top/bar`,
hbs`BAR`
);

View File

@ -6,7 +6,7 @@ import {
import { hbs } from "ember-cli-htmlbars";
import { test } from "qunit";
import { visit } from "@ember/test-helpers";
import { registerTemplateModule } from "../helpers/template-module-helper";
import { registerTemporaryModule } from "../helpers/temporary-module-helper";
const HELLO =
"discourse/plugins/my-plugin/templates/connectors/user-profile-primary/hello";
@ -15,8 +15,11 @@ const GOODBYE =
acceptance("Plugin Outlet - Multi Template", function (needs) {
needs.hooks.beforeEach(() => {
registerTemplateModule(HELLO, hbs`<span class='hello-span'>Hello</span>`);
registerTemplateModule(GOODBYE, hbs`<span class='bye-span'>Goodbye</span>`);
registerTemporaryModule(HELLO, hbs`<span class='hello-span'>Hello</span>`);
registerTemporaryModule(
GOODBYE,
hbs`<span class='bye-span'>Goodbye</span>`
);
});
test("Renders a template into the outlet", async function (assert) {

View File

@ -6,14 +6,14 @@ import {
import { hbs } from "ember-cli-htmlbars";
import { test } from "qunit";
import { visit } from "@ember/test-helpers";
import { registerTemplateModule } from "../helpers/template-module-helper";
import { registerTemporaryModule } from "../helpers/temporary-module-helper";
const CONNECTOR_MODULE =
"discourse/theme-12/templates/connectors/user-profile-primary/hello";
acceptance("Plugin Outlet - Single Template", function (needs) {
needs.hooks.beforeEach(() => {
registerTemplateModule(
registerTemporaryModule(
CONNECTOR_MODULE,
hbs`<span class='hello-username'>{{model.username}}</span>`
);

View File

@ -76,7 +76,7 @@ import { resetNotificationTypeRenderers } from "discourse/lib/notification-types
import { resetUserMenuTabs } from "discourse/lib/user-menu/tab";
import { reset as resetLinkLookup } from "discourse/lib/link-lookup";
import { resetModelTransformers } from "discourse/lib/model-transformers";
import { cleanupTemporaryTemplateRegistrations } from "./template-module-helper";
import { cleanupTemporaryModuleRegistrations } from "./temporary-module-helper";
export function currentUser() {
return User.create(sessionFixtures["/session/current.json"].current_user);
@ -208,7 +208,7 @@ export function testCleanup(container, app) {
resetUserMenuTabs();
resetLinkLookup();
resetModelTransformers();
cleanupTemporaryTemplateRegistrations();
cleanupTemporaryModuleRegistrations();
}
export function discourseModule(name, options) {

View File

@ -3,28 +3,28 @@ import { expireConnectorCache } from "discourse/lib/plugin-connectors";
const modifications = [];
function generateTemplateModule(template) {
function generateTemporaryModule(defaultExport) {
return function (_exports) {
Object.defineProperty(_exports, "__esModule", {
value: true,
});
_exports.default = template;
_exports.default = defaultExport;
};
}
export function registerTemplateModule(moduleName, template) {
export function registerTemporaryModule(moduleName, defaultExport) {
const modificationData = {
moduleName,
existingModule: requirejs.entries[moduleName],
};
delete requirejs.entries[moduleName];
define(moduleName, ["exports"], generateTemplateModule(template));
define(moduleName, ["exports"], generateTemporaryModule(defaultExport));
modifications.push(modificationData);
expireConnectorCache();
DiscourseTemplateMap.setModuleNames(Object.keys(requirejs.entries));
}
export function cleanupTemporaryTemplateRegistrations() {
export function cleanupTemporaryModuleRegistrations() {
for (const modificationData of modifications.reverse()) {
const { moduleName, existingModule } = modificationData;
delete requirejs.entries[moduleName];

View File

@ -0,0 +1,172 @@
import { assert, module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { registerTemporaryModule } from "../helpers/temporary-module-helper";
import { setComponentTemplate } from "@glimmer/manager";
import Component from "@glimmer/component";
class MockColocatedComponent extends Component {}
setComponentTemplate(hbs`Colocated Original`, MockColocatedComponent);
class MockResolvedComponent extends Component {}
const MockResolvedComponentTemplate = hbs`Resolved Original`;
const TestTemplate = hbs`
<div id='mock-colocated'><MockColocated /></div>
<div id='mock-resolved'><MockResolved /></div>
`;
function registerBaseComponents(namespace = "discourse") {
registerTemporaryModule(
`${namespace}/components/mock-colocated`,
MockColocatedComponent
);
registerTemporaryModule(
`${namespace}/components/mock-resolved`,
MockResolvedComponent
);
registerTemporaryModule(
`${namespace}/templates/components/mock-resolved`,
MockResolvedComponentTemplate
);
}
function registerThemeOverrides() {
registerTemporaryModule(
"discourse/theme-12/discourse/templates/components/mock-colocated",
hbs`Colocated Theme Override`
);
registerTemporaryModule(
"discourse/theme-12/discourse/templates/components/mock-resolved",
hbs`Resolved Theme Override`
);
}
function registerPluginOverrides() {
registerTemporaryModule(
`discourse/plugins/some-plugin-name/discourse/templates/components/mock-colocated`,
hbs`Colocated Plugin Override`
);
registerTemporaryModule(
`discourse/plugins/some-plugin-name/discourse/templates/components/mock-resolved`,
hbs`Resolved Plugin Override`
);
}
function registerOtherPluginOverrides() {
registerTemporaryModule(
`discourse/plugins/other-plugin-name/discourse/templates/components/mock-colocated`,
hbs`Colocated Other Plugin Override`
);
registerTemporaryModule(
`discourse/plugins/other-plugin-name/discourse/templates/components/mock-resolved`,
hbs`Resolved Other Plugin Override`
);
}
module("Integration | Initializers | template-overrides", function () {
module("with no overrides", function (hooks) {
hooks.beforeEach(() => registerBaseComponents());
setupRenderingTest(hooks);
test("renders core templates when there are no overrides", async function () {
await render(TestTemplate);
assert
.dom("#mock-colocated")
.hasText("Colocated Original", "colocated component correct");
assert
.dom("#mock-resolved")
.hasText("Resolved Original", "resolved component correct");
});
});
module("with theme overrides", function (hooks) {
hooks.beforeEach(() => registerBaseComponents());
hooks.beforeEach(registerThemeOverrides);
setupRenderingTest(hooks);
test("theme overrides are used", async function () {
await render(TestTemplate);
assert
.dom("#mock-colocated")
.hasText("Colocated Theme Override", "colocated component correct");
assert
.dom("#mock-resolved")
.hasText("Resolved Theme Override", "resolved component correct");
});
});
module("with plugin overrides", function (hooks) {
hooks.beforeEach(() => registerBaseComponents());
hooks.beforeEach(registerPluginOverrides);
setupRenderingTest(hooks);
test("plugin overrides are used", async function () {
await render(TestTemplate);
assert
.dom("#mock-colocated")
.hasText("Colocated Plugin Override", "colocated component correct");
assert
.dom("#mock-resolved")
.hasText("Resolved Plugin Override", "resolved component correct");
});
});
module("with theme and plugin overrides", function (hooks) {
hooks.beforeEach(registerPluginOverrides);
hooks.beforeEach(registerThemeOverrides);
setupRenderingTest(hooks);
test("plugin overrides are used", async function () {
await render(TestTemplate);
assert
.dom("#mock-colocated")
.hasText("Colocated Theme Override", "colocated component correct");
assert
.dom("#mock-resolved")
.hasText("Resolved Theme Override", "resolved component correct");
});
});
module("with multiple plugin overrides", function (hooks) {
hooks.beforeEach(() => registerBaseComponents());
hooks.beforeEach(registerPluginOverrides);
hooks.beforeEach(registerOtherPluginOverrides);
setupRenderingTest(hooks);
test("last-defined plugin overrides are used", async function () {
await render(TestTemplate);
assert
.dom("#mock-colocated")
.hasText(
"Colocated Other Plugin Override",
"colocated component correct"
);
assert
.dom("#mock-resolved")
.hasText(
"Resolved Other Plugin Override",
"resolved component correct"
);
});
});
module("theme overriding plugin", function (hooks) {
hooks.beforeEach(() =>
registerBaseComponents("discourse/plugins/base-plugin/discourse")
);
hooks.beforeEach(registerThemeOverrides);
setupRenderingTest(hooks);
test("theme overrides plugin component", async function () {
await render(TestTemplate);
assert
.dom("#mock-colocated")
.hasText("Colocated Theme Override", "colocated component correct");
assert
.dom("#mock-resolved")
.hasText("Resolved Theme Override", "resolved component correct");
});
});
});

View File

@ -1,6 +1,6 @@
import { buildResolver, setResolverOption } from "discourse-common/resolver";
import { module, test } from "qunit";
import { registerTemplateModule } from "discourse/tests/helpers/template-module-helper";
import { registerTemporaryModule } from "discourse/tests/helpers/temporary-module-helper";
import DiscourseTemplateMap from "discourse-common/lib/discourse-template-map";
let resolver;
@ -13,7 +13,7 @@ function lookupTemplate(assert, name, expectedTemplate, message) {
function setTemplates(templateModuleNames) {
for (const name of templateModuleNames) {
registerTemplateModule(name, name);
registerTemporaryModule(name, name);
}
}