FEATURE: Add filter box to the themes/components list (#13767)

FEATURE: Add filter box to the themes/components list (#13767)

diff --git a/app/assets/javascripts/admin/addon/components/themes-list.js b/app/assets/javascripts/admin/addon/components/themes-list.js
index 715ca9c..ca1d872 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);
+  },
+
+  _filterThemes(themes, term) {
+    term = term?.trim()?.toLowerCase();
+    if (!term) {
+      return themes;
+    }
+    return themes.filter(({ name }) => name.toLowerCase().includes(term));
   },
 
-  actions: {
-    changeView(newTab) {
-      if (newTab !== this.currentTab) {
-        this.set("currentTab", newTab);
+  @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 a02fb2f..ab342b1 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 @@
 </div>
 
 <div class="themes-list-container">
+  {{#if showFilter}}
+    <div class="themes-list-filter themes-list-item">
+      {{input
+        class="filter-input"
+        placeholder=(i18n "admin.customize.theme.filter_placeholder")
+        autocomplete="discourse"
+        value=(mut filterTerm)
+      }}
+      {{d-icon "search"}}
+    </div>
+  {{/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 375340a..cb820a2 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",

[... diff too long, it was truncated ...]

GitHub sha: 1c82989f779a01721fdc2b6e23c3e7aab7b1a338

This commit appears in #13767 which was approved by eviltrout. It was merged by tgxworld.