diff --git a/app/assets/javascripts/admin/addon/components/themes-list.js b/app/assets/javascripts/admin/addon/components/themes-list.js
index 715ca9cceef..ca1d872f46b 100644
--- a/app/assets/javascripts/admin/addon/components/themes-list.js
+++ b/app/assets/javascripts/admin/addon/components/themes-list.js
@@ -1,8 +1,9 @@
import { COMPONENTS, THEMES } from "admin/models/theme";
-import { equal, gt } from "@ember/object/computed";
+import { equal, gt, gte } from "@ember/object/computed";
import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators";
import { inject as service } from "@ember/service";
+import { action } from "@ember/object";
export default Component.extend({
router: service(),
@@ -10,10 +11,12 @@ export default Component.extend({
COMPONENTS,
classNames: ["themes-list"],
+ filterTerm: null,
hasThemes: gt("themesList.length", 0),
hasActiveThemes: gt("activeThemes.length", 0),
hasInactiveThemes: gt("inactiveThemes.length", 0),
+ showFilter: gte("themesList.length", 10),
themesTabActive: equal("currentTab", THEMES),
componentsTabActive: equal("currentTab", COMPONENTS),
@@ -31,28 +34,36 @@ export default Component.extend({
"themesList",
"currentTab",
"themesList.@each.user_selectable",
- "themesList.@each.default"
+ "themesList.@each.default",
+ "filterTerm"
)
inactiveThemes(themes) {
+ let results;
if (this.componentsTabActive) {
- return themes.filter((theme) => theme.get("parent_themes.length") <= 0);
+ results = themes.filter(
+ (theme) => theme.get("parent_themes.length") <= 0
+ );
+ } else {
+ results = themes.filter(
+ (theme) => !theme.get("user_selectable") && !theme.get("default")
+ );
}
- return themes.filter(
- (theme) => !theme.get("user_selectable") && !theme.get("default")
- );
+ return this._filterThemes(results, this.filterTerm);
},
@discourseComputed(
"themesList",
"currentTab",
"themesList.@each.user_selectable",
- "themesList.@each.default"
+ "themesList.@each.default",
+ "filterTerm"
)
activeThemes(themes) {
+ let results;
if (this.componentsTabActive) {
- return themes.filter((theme) => theme.get("parent_themes.length") > 0);
+ results = themes.filter((theme) => theme.get("parent_themes.length") > 0);
} else {
- return themes
+ results = themes
.filter((theme) => theme.get("user_selectable") || theme.get("default"))
.sort((a, b) => {
if (a.get("default") && !b.get("default")) {
@@ -66,16 +77,29 @@ export default Component.extend({
.localeCompare(b.get("name").toLowerCase());
});
}
+ return this._filterThemes(results, this.filterTerm);
},
- actions: {
- changeView(newTab) {
- if (newTab !== this.currentTab) {
- this.set("currentTab", newTab);
+ _filterThemes(themes, term) {
+ term = term?.trim()?.toLowerCase();
+ if (!term) {
+ return themes;
+ }
+ return themes.filter(({ name }) => name.toLowerCase().includes(term));
+ },
+
+ @action
+ changeView(newTab) {
+ if (newTab !== this.currentTab) {
+ this.set("currentTab", newTab);
+ if (!this.showFilter) {
+ this.set("filterTerm", null);
}
- },
- navigateToTheme(theme) {
- this.router.transitionTo("adminCustomizeThemes.show", theme);
- },
+ }
+ },
+
+ @action
+ navigateToTheme(theme) {
+ this.router.transitionTo("adminCustomizeThemes.show", theme);
},
});
diff --git a/app/assets/javascripts/admin/addon/templates/components/themes-list.hbs b/app/assets/javascripts/admin/addon/templates/components/themes-list.hbs
index a02fb2f2ce3..ab342b1b930 100644
--- a/app/assets/javascripts/admin/addon/templates/components/themes-list.hbs
+++ b/app/assets/javascripts/admin/addon/templates/components/themes-list.hbs
@@ -15,6 +15,17 @@
+ {{#if showFilter}}
+
+ {{input
+ class="filter-input"
+ placeholder=(i18n "admin.customize.theme.filter_placeholder")
+ autocomplete="discourse"
+ value=(mut filterTerm)
+ }}
+ {{d-icon "search"}}
+
+ {{/if}}
{{#if hasThemes}}
{{#if hasActiveThemes}}
{{#each activeThemes as |theme|}}
diff --git a/app/assets/javascripts/discourse/tests/integration/components/themes-list-test.js b/app/assets/javascripts/discourse/tests/integration/components/themes-list-test.js
index 375340ae6e6..cb820a21605 100644
--- a/app/assets/javascripts/discourse/tests/integration/components/themes-list-test.js
+++ b/app/assets/javascripts/discourse/tests/integration/components/themes-list-test.js
@@ -6,26 +6,37 @@ import componentTest, {
import {
count,
discourseModule,
+ exists,
+ query,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import hbs from "htmlbars-inline-precompile";
+import { click, fillIn } from "@ember/test-helpers";
+
+function createThemes(itemsCount, customAttributesCallback) {
+ return [...Array(itemsCount)].map((_, i) => {
+ const attrs = { name: `Theme ${i + 1}` };
+ if (customAttributesCallback) {
+ Object.assign(attrs, customAttributesCallback(i + 1));
+ }
+ return Theme.create(attrs);
+ });
+}
discourseModule("Integration | Component | themes-list", function (hooks) {
setupRenderingTest(hooks);
componentTest("current tab is themes", {
template: hbs`{{themes-list themes=themes components=components currentTab=currentTab}}`,
beforeEach() {
- this.themes = [1, 2, 3, 4, 5].map((num) =>
- Theme.create({ name: `Theme ${num}` })
- );
- this.components = [1, 2, 3, 4, 5].map((num) =>
- Theme.create({
- name: `Child ${num}`,
+ this.themes = createThemes(5);
+ this.components = createThemes(5, (n) => {
+ return {
+ name: `Child ${n}`,
component: true,
- parentThemes: [this.themes[num - 1]],
+ parentThemes: [this.themes[n - 1]],
parent_themes: [1, 2, 3, 4, 5],
- })
- );
+ };
+ });
this.setProperties({
themes: this.themes,
components: this.components,
@@ -94,17 +105,15 @@ discourseModule("Integration | Component | themes-list", function (hooks) {
componentTest("current tab is components", {
template: hbs`{{themes-list themes=themes components=components currentTab=currentTab}}`,
beforeEach() {
- this.themes = [1, 2, 3, 4, 5].map((num) =>
- Theme.create({ name: `Theme ${num}` })
- );
- this.components = [1, 2, 3, 4, 5].map((num) =>
- Theme.create({
- name: `Child ${num}`,
+ this.themes = createThemes(5);
+ this.components = createThemes(5, (n) => {
+ return {
+ name: `Child ${n}`,
component: true,
- parentThemes: [this.themes[num - 1]],
+ parentThemes: [this.themes[n - 1]],
parent_themes: [1, 2, 3, 4, 5],
- })
- );
+ };
+ });
this.setProperties({
themes: this.themes,
components: this.components,
@@ -144,4 +153,139 @@ discourseModule("Integration | Component | themes-list", function (hooks) {
);
},
});
+
+ componentTest(
+ "themes filter is not visible when there are less than 10 themes",
+ {
+ template: hbs`{{themes-list themes=themes components=[] currentTab=currentTab}}`,
+
+ beforeEach() {
+ const themes = createThemes(9);
+ this.setProperties({
+ themes,
+ currentTab: THEMES,
+ });
+ },
+ async test(assert) {
+ assert.ok(
+ !exists(".themes-list-filter"),
+ "filter input not shown when we have fewer than 10 themes"
+ );
+ },
+ }
+ );
+
+ componentTest(
+ "themes filter keeps themes whose names include the filter term",
+ {
+ template: hbs`{{themes-list themes=themes components=[] currentTab=currentTab}}`,
+
+ beforeEach() {
+ const themes = ["osama", "OsAmaa", "osAMA 1234"]
+ .map((name) => Theme.create({ name: `Theme ${name}` }))
+ .concat(createThemes(7));
+ this.setProperties({
+ themes,
+ currentTab: THEMES,
+ });
+ },
+ async test(assert) {
+ assert.ok(exists(".themes-list-filter"));
+ await fillIn(".themes-list-filter .filter-input", " oSAma ");
+ assert.deepEqual(
+ Array.from(queryAll(".themes-list-item .name")).map((node) =>
+ node.textContent.trim()
+ ),
+ ["Theme osama", "Theme OsAmaa", "Theme osAMA 1234"],
+ "only themes whose names include the filter term are shown"
+ );
+ },
+ }
+ );
+
+ componentTest(
+ "switching between themes and components tabs keeps the filter visible only if both tabs have at least 10 items",
+ {
+ template: hbs`{{themes-list themes=themes components=components currentTab=currentTab}}`,
+
+ beforeEach() {
+ const themes = createThemes(10, (n) => {
+ return { name: `Theme ${n}${n}` };
+ });
+ const components = createThemes(5, (n) => {
+ return {
+ name: `Component ${n}${n}`,
+ component: true,
+ parent_themes: [1],
+ parentThemes: [1],
+ };
+ });
+ this.setProperties({
+ themes,
+ components,
+ currentTab: THEMES,
+ });
+ },
+ async test(assert) {
+ await fillIn(".themes-list-filter .filter-input", "11");
+ assert.equal(
+ query(".themes-list-container").textContent.trim(),
+ "Theme 11",
+ "only 1 theme is shown"
+ );
+ await click(".themes-list-header .components-tab");
+ assert.ok(
+ !exists(".themes-list-filter"),
+ "filter input/term do not persist when we switch to the other" +
+ " tab because it has fewer than 10 items"
+ );
+ assert.deepEqual(
+ Array.from(queryAll(".themes-list-item .name")).map((node) =>
+ node.textContent.trim()
+ ),
+ [
+ "Component 11",
+ "Component 22",
+ "Component 33",
+ "Component 44",
+ "Component 55",
+ ],
+ "all components are shown"
+ );
+
+ this.set(
+ "components",
+ this.components.concat(
+ createThemes(5, (n) => {
+ n += 5;
+ return {
+ name: `Component ${n}${n}`,
+ component: true,
+ parent_themes: [1],
+ parentThemes: [1],
+ };
+ })
+ )
+ );
+ assert.ok(
+ exists(".themes-list-filter"),
+ "filter is now shown for the components tab"
+ );
+
+ await fillIn(".themes-list-filter .filter-input", "66");
+ assert.equal(
+ query(".themes-list-container").textContent.trim(),
+ "Component 66",
+ "only 1 component is shown"
+ );
+
+ await click(".themes-list-header .themes-tab");
+ assert.equal(
+ query(".themes-list-container").textContent.trim(),
+ "Theme 66",
+ "filter term persisted between tabs because both have more than 10 items"
+ );
+ },
+ }
+ );
});
diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss
index 045235d3c17..4eeb0811424 100644
--- a/app/assets/stylesheets/common/admin/customize.scss
+++ b/app/assets/stylesheets/common/admin/customize.scss
@@ -295,6 +295,37 @@
width: 100%;
}
}
+
+ .themes-list-filter {
+ display: flex;
+ align-items: center;
+ position: sticky;
+ top: 0;
+ background: var(--secondary);
+ z-index: z("base");
+ height: 3em;
+
+ .d-icon {
+ position: absolute;
+ padding-left: 0.5em;
+ }
+
+ .filter-input {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ border: 0;
+ padding-left: 2em;
+
+ &:focus {
+ outline: 0;
+
+ ~ .d-icon {
+ color: var(--tertiary-hover);
+ }
+ }
+ }
+ }
}
.theme.settings {
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 4eda0fe814c..0a5974c9fee 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -4230,6 +4230,7 @@ en:
theme: "Theme"
component: "Component"
components: "Components"
+ filter_placeholder: "type to filter…"
theme_name: "Theme name"
component_name: "Component name"
themes_intro: "Select an existing theme or install a new one to get started"