From 1c82989f779a01721fdc2b6e23c3e7aab7b1a338 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Mon, 19 Jul 2021 04:33:58 +0300 Subject: [PATCH] FEATURE: Add filter box to the themes/components list (#13767) --- .../admin/addon/components/themes-list.js | 58 ++++-- .../templates/components/themes-list.hbs | 11 ++ .../components/themes-list-test.js | 180 ++++++++++++++++-- .../stylesheets/common/admin/customize.scss | 31 +++ config/locales/client.en.yml | 1 + 5 files changed, 246 insertions(+), 35 deletions(-) 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"