FEATURE: Introduce DBreadcrumbs components (#27049)

This commit introduces the following components:

* DBreadcrumbsContainer - The wrapper template-only component,
  which renders all DBreadcrumbsItem components on the page.
* DBreadcrumbsItem - The component that registers a LinkTo
  for the breadcrumb trail. The breadcrumb > trail > will
  show based on the order these items are rendered on the page.
* BreadcrumbsService - Manages the DBreadcrumbsContainer elements
  on the page via DBreadcrumbsContainerModifier.
* DBreadcrumbsContainerModifier - Handles registering DBreadcrumbsContainer
  elements with the BreadcrumbsService and deregistering them.

For now, we will only use these breadcrumbs in the admin section
of Discourse, and this initial commit only uses them in admin/plugins.

This is heavily based off of
https://github.com/Bagaar/ember-breadcrumbs,
but will be further modified for our needs.
This commit is contained in:
Martin Brennan 2024-05-20 14:25:54 +10:00 committed by GitHub
parent f2cdc3b2a4
commit 1239178f49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 236 additions and 8 deletions

View File

@ -1,5 +1,9 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import DBreadcrumbsContainer from "discourse/components/d-breadcrumbs-container";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import i18n from "discourse-common/helpers/i18n";
import AdminPluginConfigArea from "./admin-plugin-config-area"; import AdminPluginConfigArea from "./admin-plugin-config-area";
import AdminPluginConfigMetadata from "./admin-plugin-config-metadata"; import AdminPluginConfigMetadata from "./admin-plugin-config-metadata";
import AdminPluginConfigTopNav from "./admin-plugin-config-top-nav"; import AdminPluginConfigTopNav from "./admin-plugin-config-top-nav";
@ -26,6 +30,30 @@ export default class AdminPluginConfigPage extends Component {
<AdminPluginConfigTopNav /> <AdminPluginConfigTopNav />
{{/if}} {{/if}}
<DBreadcrumbsContainer />
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="admin" class={{linkClass}}>
{{i18n "admin_title"}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="adminPlugins" class={{linkClass}}>
{{i18n "admin.plugins.title"}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem as |linkClass|>
<LinkTo
@route="adminPlugins.show"
@model={{@plugin}}
class={{linkClass}}
>
{{@plugin.nameTitleized}}
</LinkTo>
</DBreadcrumbsItem>
<AdminPluginConfigMetadata @plugin={{@plugin}} /> <AdminPluginConfigMetadata @plugin={{@plugin}} />
<div class="admin-plugin-config-page__content"> <div class="admin-plugin-config-page__content">

View File

@ -1,3 +1,17 @@
<DBreadcrumbsContainer />
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="admin" class={{linkClass}}>
{{i18n "admin_title"}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="adminPlugins" class={{linkClass}}>
{{i18n "admin.plugins.title"}}
</LinkTo>
</DBreadcrumbsItem>
<div class="admin-plugins-list-container"> <div class="admin-plugins-list-container">
{{#if this.model.length}} {{#if this.model.length}}
<h2>{{i18n "admin.plugins.installed"}}</h2> <h2>{{i18n "admin.plugins.installed"}}</h2>

View File

@ -1,3 +1,13 @@
<DBreadcrumbsItem as |linkClass|>
<LinkTo
@route="adminPlugins.show.settings"
@model={{@model.plugin}}
class={{linkClass}}
>
{{i18n "admin.plugins.change_settings_short"}}
</LinkTo>
</DBreadcrumbsItem>
<div <div
class="content-body admin-plugin-config-area__settings admin-detail pull-left" class="content-body admin-plugin-config-area__settings admin-detail pull-left"
> >

View File

@ -0,0 +1,17 @@
import concatClass from "discourse/helpers/concat-class";
import dBreadcrumbsContainerModifier from "discourse/modifiers/d-breadcrumbs-container-modifier";
const DBreadcrumbsContainer = <template>
<ul
class="d-breadcrumbs"
{{dBreadcrumbsContainerModifier
itemClass=(concatClass "d-breadcrumbs__item" @additionalItemClasses)
linkClass=(concatClass "d-breadcrumbs__link" @additionalLinkClasses)
}}
...attributes
>
{{yield}}
</ul>
</template>;
export default DBreadcrumbsContainer;

View File

@ -0,0 +1,16 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class DBreadcrumbsItem extends Component {
@service breadcrumbsService;
<template>
{{#each this.breadcrumbsService.containers as |container|}}
{{#in-element container.element insertBefore=null}}
<li class={{container.itemClass}} ...attributes>
{{yield container.linkClass}}
</li>
{{/in-element}}
{{/each}}
</template>
}

View File

@ -0,0 +1,27 @@
import { registerDestructor } from "@ember/destroyable";
import { inject as service } from "@ember/service";
import Modifier from "ember-modifier";
export default class DBreadcrumbsContainerModifier extends Modifier {
@service breadcrumbsService;
container = null;
modify(element, _, { itemClass, linkClass }) {
if (this.container) {
return;
}
this.container = { element, itemClass, linkClass };
this.breadcrumbsService.registerContainer(this.container);
registerDestructor(this, unregisterContainer);
}
}
function unregisterContainer(instance) {
if (instance.container) {
instance.breadcrumbsService.unregisterContainer(instance.container);
}
}

View File

@ -0,0 +1,40 @@
import { tracked } from "@glimmer/tracking";
import { warn } from "@ember/debug";
import Service from "@ember/service";
export default class BreadcrumbsService extends Service {
@tracked containers = [];
#containers = [];
registerContainer(container) {
if (this.#isContainerRegistered(container)) {
warn(
"[BreadcrumbsService] A breadcrumb container with the same DOM element has already been registered before."
);
}
this.#containers = [...this.#containers, container];
this.containers = this.#containers;
}
unregisterContainer(container) {
if (!this.#isContainerRegistered(container)) {
warn(
"[BreadcrumbsService] No breadcrumb container was found with this DOM element."
);
}
this.#containers = this.#containers.filter((registeredContainer) => {
return container.element !== registeredContainer.element;
});
this.containers = this.#containers;
}
#isContainerRegistered(container) {
return this.#containers.some((registeredContainer) => {
return container.element === registeredContainer.element;
});
}
}

View File

@ -0,0 +1,64 @@
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module(
"Component | DBreadcrumbsContainer and DBreadcrumbsItem",
function (hooks) {
setupRenderingTest(hooks);
test("it renders a DBreadcrumbsContainer with multiple DBreadcrumbsItems", async function (assert) {
await render(hbs`
<DBreadcrumbsContainer />
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="admin" class={{linkClass}}>
{{i18n "admin_title"}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="about" class={{linkClass}}>
{{i18n "about.simple_title"}}
</LinkTo>
</DBreadcrumbsItem>
`);
assert
.dom(".d-breadcrumbs .d-breadcrumbs__item .d-breadcrumbs__link")
.exists({ count: 2 });
});
test("it renders a DBreadcrumbsItem with additional link and item classes", async function (assert) {
await render(hbs`
<DBreadcrumbsContainer @additionalLinkClasses="some-class" @additionalItemClasses="other-class" />
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="admin" class={{linkClass}}>
{{i18n "admin_title"}}
</LinkTo>
</DBreadcrumbsItem>
`);
assert.dom(".d-breadcrumbs .d-breadcrumbs__item.other-class").exists();
assert
.dom(
".d-breadcrumbs .d-breadcrumbs__item .d-breadcrumbs__link.some-class"
)
.exists();
});
test("it renders multiple DBreadcrumbsContainer elements with the same DBreadcrumbsItem links", async function (assert) {
await render(hbs`
<DBreadcrumbsContainer />
<DBreadcrumbsContainer />
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="admin" class={{linkClass}}>
{{i18n "admin_title"}}
</LinkTo>
</DBreadcrumbsItem>
`);
assert.dom(".d-breadcrumbs").exists({ count: 2 });
assert.dom(".d-breadcrumbs .d-breadcrumbs__item").exists({ count: 2 });
});
}
);

View File

@ -84,10 +84,6 @@
} }
.admin-plugin-config-page { .admin-plugin-config-page {
.admin-controls {
margin-bottom: 1em;
}
&__main-area { &__main-area {
.admin-detail { .admin-detail {
padding-top: 15px; padding-top: 15px;
@ -107,10 +103,6 @@
margin-top: 0; margin-top: 0;
} }
.admin-plugins-list-container {
margin-top: 1em;
}
.admin-plugin-filtered-site-settings { .admin-plugin-filtered-site-settings {
&__filter { &__filter {
width: 100%; width: 100%;

View File

@ -1,5 +1,6 @@
@import "badges"; @import "badges";
@import "banner"; @import "banner";
@import "d-breadcrumbs";
@import "bookmark-list"; @import "bookmark-list";
@import "bookmark-modal"; @import "bookmark-modal";
@import "bookmark-menu"; @import "bookmark-menu";

View File

@ -0,0 +1,19 @@
.d-breadcrumbs {
display: flex;
margin: 1em 0 0.5em 0;
&__item {
list-style-type: none;
}
&__link,
&__link:visited {
color: var(--primary-medium);
}
li:not(:last-child) a::after {
display: inline;
padding: 0 0.25em;
content: ">";
}
}