FEATURE: filter themes and components (#25105)

Allow filtering themes or components to find Active/Enabled Inactive/Disabled or Updates Available in the admin panel.
This commit is contained in:
Krzysztof Kotlarek 2024-01-04 14:29:08 +11:00 committed by GitHub
parent e9f016726a
commit be841e666e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 242 additions and 69 deletions

View File

@ -8,7 +8,7 @@ import discourseComputed from "discourse-common/utils/decorators";
const MAX_COMPONENTS = 4;
@classNames("themes-list-item")
@classNames("themes-list-container__item")
@classNameBindings("theme.selected:selected")
export default class ThemesListItem extends Component {
childrenExpanded = false;

View File

@ -17,20 +17,30 @@
</div>
<div class="themes-list-container">
{{#if this.showFilter}}
<div class="themes-list-filter themes-list-item">
{{#if this.showSearch}}
<div class="themes-list-container__search themes-list-container__item">
<Input
class="filter-input"
placeholder={{i18n "admin.customize.theme.filter_placeholder"}}
class="themes-list-container__search-input"
placeholder={{i18n "admin.customize.theme.search_placeholder"}}
autocomplete="off"
@type="search"
@value={{mut this.filterTerm}}
@value={{mut this.searchTerm}}
/>
{{d-icon "search"}}
</div>
{{/if}}
<div class="themes-list-container__filter themes-list-container__item">
<div class="themes-list-container__filter-label">
{{i18n "admin.customize.theme.filter_by"}}
</div>
<ComboBox
@content={{this.selectableFilters}}
@value={{this.filter}}
@class="themes-list-container__filter-input"
/>
</div>
{{#if this.hasThemes}}
{{#if this.hasActiveThemes}}
{{#if (and this.hasActiveThemes (not this.inactiveFilter))}}
{{#each this.activeThemes as |theme|}}
<ThemesListItem
@theme={{theme}}
@ -38,8 +48,8 @@
/>
{{/each}}
{{#if this.hasInactiveThemes}}
<div class="themes-list-item inactive-indicator">
{{#if (and this.hasInactiveThemes (not this.activeFilter))}}
<div class="themes-list-container__item inactive-indicator">
<span class="empty">
<div class="info">
{{#if this.selectInactiveMode}}
@ -101,7 +111,7 @@
{{/if}}
{{/if}}
{{#if this.hasInactiveThemes}}
{{#if (and this.hasInactiveThemes (not this.activeFilter))}}
{{#each this.inactiveThemes as |theme|}}
<ThemesListItem
@classNames="inactive-theme"
@ -112,7 +122,7 @@
{{/each}}
{{/if}}
{{else}}
<div class="themes-list-item">
<div class="themes-list-container__item">
<span class="empty">{{i18n "admin.customize.theme.empty"}}</span>
</div>
{{/if}}

View File

@ -5,8 +5,42 @@ import { inject as service } from "@ember/service";
import { classNames } from "@ember-decorators/component";
import DeleteThemesConfirm from "discourse/components/modal/delete-themes-confirm";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import { COMPONENTS, THEMES } from "admin/models/theme";
const ALL_FILTER = "all";
const ACTIVE_FILTER = "active";
const INACTIVE_FILTER = "inactive";
const UPDATES_AVAILABLE_FILTER = "updates_available";
const THEMES_FILTERS = [
{ name: I18n.t("admin.customize.theme.all_filter"), id: ALL_FILTER },
{ name: I18n.t("admin.customize.theme.active_filter"), id: ACTIVE_FILTER },
{
name: I18n.t("admin.customize.theme.inactive_filter"),
id: INACTIVE_FILTER,
},
{
name: I18n.t("admin.customize.theme.updates_available_filter"),
id: UPDATES_AVAILABLE_FILTER,
},
];
const COMPONENTS_FILTERS = [
{ name: I18n.t("admin.customize.component.all_filter"), id: ALL_FILTER },
{
name: I18n.t("admin.customize.component.enabled_filter"),
id: ACTIVE_FILTER,
},
{
name: I18n.t("admin.customize.component.disabled_filter"),
id: INACTIVE_FILTER,
},
{
name: I18n.t("admin.customize.component.updates_available_filter"),
id: UPDATES_AVAILABLE_FILTER,
},
];
@classNames("themes-list")
export default class ThemesList extends Component {
@service router;
@ -14,7 +48,8 @@ export default class ThemesList extends Component {
THEMES = THEMES;
COMPONENTS = COMPONENTS;
filterTerm = null;
searchTerm = null;
filter = ALL_FILTER;
selectInactiveMode = false;
@gt("themesList.length", 0) hasThemes;
@ -23,12 +58,15 @@ export default class ThemesList extends Component {
@gt("inactiveThemes.length", 0) hasInactiveThemes;
@gte("themesList.length", 10) showFilter;
@gte("themesList.length", 10) showSearch;
@equal("currentTab", THEMES) themesTabActive;
@equal("currentTab", COMPONENTS) componentsTabActive;
@equal("filter", ACTIVE_FILTER) activeFilter;
@equal("filter", INACTIVE_FILTER) inactiveFilter;
@discourseComputed("themes", "components", "currentTab")
themesList(themes, components) {
if (this.themesTabActive) {
@ -38,13 +76,23 @@ export default class ThemesList extends Component {
}
}
@discourseComputed("currentTab")
selectableFilters() {
if (this.themesTabActive) {
return THEMES_FILTERS;
} else {
return COMPONENTS_FILTERS;
}
}
@discourseComputed(
"themesList",
"currentTab",
"themesList.@each.user_selectable",
"themesList.@each.default",
"themesList.@each.markedToDelete",
"filterTerm"
"searchTerm",
"filter"
)
inactiveThemes(themes) {
let results;
@ -57,7 +105,10 @@ export default class ThemesList extends Component {
(theme) => !theme.get("user_selectable") && !theme.get("default")
);
}
return this._filterThemes(results, this.filterTerm);
if (this.filter === UPDATES_AVAILABLE_FILTER) {
results = results.filterBy("isPendingUpdates");
}
return this._searchThemes(results, this.searchTerm);
}
@discourseComputed("themesList.@each.markedToDelete")
@ -75,7 +126,8 @@ export default class ThemesList extends Component {
"currentTab",
"themesList.@each.user_selectable",
"themesList.@each.default",
"filterTerm"
"searchTerm",
"filter"
)
activeThemes(themes) {
let results;
@ -96,7 +148,10 @@ export default class ThemesList extends Component {
.localeCompare(b.get("name").toLowerCase());
});
}
return this._filterThemes(results, this.filterTerm);
if (this.filter === UPDATES_AVAILABLE_FILTER) {
results = results.filterBy("isPendingUpdates");
}
return this._searchThemes(results, this.searchTerm);
}
@discourseComputed("themesList.@each.markedToDelete")
someInactiveSelected() {
@ -111,7 +166,7 @@ export default class ThemesList extends Component {
return this.selectedCount === this.inactiveThemes.length;
}
_filterThemes(themes, term) {
_searchThemes(themes, term) {
term = term?.trim()?.toLowerCase();
if (!term) {
return themes;
@ -131,8 +186,9 @@ export default class ThemesList extends Component {
if (newTab !== this.currentTab) {
this.set("selectInactiveMode", false);
this.set("currentTab", newTab);
if (!this.showFilter) {
this.set("filterTerm", null);
this.set("filter", ALL_FILTER);
if (!this.showSearch) {
this.set("searchTerm", null);
}
}
}

View File

@ -215,7 +215,7 @@ acceptance("Theme", function (needs) {
test("can continue installation", async function (assert) {
await visit("/admin/customize/themes");
await click(".themes-list-container .themes-list-item");
await click(".themes-list-container__item .info");
assert.ok(
query(".control-unit .status-message").innerText.includes(
I18n.t("admin.customize.theme.last_attempt")

View File

@ -8,6 +8,7 @@ import {
query,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import I18n from "discourse-i18n";
import Theme, { COMPONENTS, THEMES } from "admin/models/theme";
@ -59,14 +60,18 @@ module("Integration | Component | themes-list", function (hooks) {
exists(".inactive-indicator"),
"there is no inactive themes separator when all themes are inactive"
);
assert.strictEqual(count(".themes-list-item"), 5, "displays all themes");
assert.strictEqual(
count(".themes-list-container__item .info"),
5,
"displays all themes"
);
[2, 3].forEach((num) => this.themes[num].set("user_selectable", true));
this.themes[4].set("default", true);
this.set("themes", this.themes);
const names = [4, 2, 3, 0, 1].map((num) => this.themes[num].get("name")); // default theme always on top, followed by user-selectable ones and then the rest
assert.deepEqual(
Array.from(queryAll(".themes-list-item .name")).map((node) =>
[...queryAll(".themes-list-container__item .info .name")].map((node) =>
node.innerText.trim()
),
names,
@ -74,7 +79,7 @@ module("Integration | Component | themes-list", function (hooks) {
);
assert.strictEqual(
queryAll(".inactive-indicator").index(),
3,
4,
"the separator is in the right location"
);
@ -87,12 +92,12 @@ module("Integration | Component | themes-list", function (hooks) {
this.set("themes", []);
assert.strictEqual(
count(".themes-list-item"),
count(".themes-list-container__item .empty"),
1,
"shows one entry with a message when there is nothing to display"
);
assert.strictEqual(
query(".themes-list-item span.empty").innerText.trim(),
query(".themes-list-container__item span.empty").innerText.trim(),
I18n.t("admin.customize.theme.empty"),
"displays the right message"
);
@ -131,25 +136,25 @@ module("Integration | Component | themes-list", function (hooks) {
assert.notOk(exists(".inactive-indicator"), "there is no separator");
assert.strictEqual(
count(".themes-list-item"),
count(".themes-list-container__item .info"),
5,
"displays all components"
);
this.set("components", []);
assert.strictEqual(
count(".themes-list-item"),
count(".themes-list-container__item .empty"),
1,
"shows one entry with a message when there is nothing to display"
);
assert.strictEqual(
query(".themes-list-item span.empty").innerText.trim(),
query(".themes-list-container__item span.empty").innerText.trim(),
I18n.t("admin.customize.theme.empty"),
"displays the right message"
);
});
test("themes filter is not visible when there are less than 10 themes", async function (assert) {
test("themes search is not visible when there are less than 10 themes", async function (assert) {
const themes = createThemes(9);
this.setProperties({
themes,
@ -161,12 +166,12 @@ module("Integration | Component | themes-list", function (hooks) {
);
assert.ok(
!exists(".themes-list-filter"),
"filter input not shown when we have fewer than 10 themes"
!exists(".themes-list-search"),
"search input not shown when we have fewer than 10 themes"
);
});
test("themes filter keeps themes whose names include the filter term", async function (assert) {
test("themes search keeps themes whose names include the search term", async function (assert) {
const themes = ["osama", "OsAmaa", "osAMA 1234"]
.map((name) => Theme.create({ name: `Theme ${name}` }))
.concat(createThemes(7));
@ -179,18 +184,90 @@ module("Integration | Component | themes-list", function (hooks) {
hbs`<ThemesList @themes={{this.themes}} @components={{(array)}} @currentTab={{this.currentTab}} />`
);
assert.ok(exists(".themes-list-filter"));
await fillIn(".themes-list-filter .filter-input", " oSAma ");
assert.ok(exists(".themes-list-container__search-input"));
await fillIn(".themes-list-container__search-input", " oSAma ");
assert.deepEqual(
Array.from(queryAll(".themes-list-item .name")).map((node) =>
[...queryAll(".themes-list-container__item .info .name")].map((node) =>
node.textContent.trim()
),
["Theme osama", "Theme OsAmaa", "Theme osAMA 1234"],
"only themes whose names include the filter term are shown"
"only themes whose names include the search term are shown"
);
});
test("switching between themes and components tabs keeps the filter visible only if both tabs have at least 10 items", async function (assert) {
test("themes filter", async function (assert) {
const themes = [
Theme.create({ name: "Theme enabled 1", user_selectable: true }),
Theme.create({ name: "Theme enabled 2", user_selectable: true }),
Theme.create({ name: "Theme disabled 1", user_selectable: false }),
Theme.create({
name: "Theme disabled 2",
user_selectable: false,
remote_theme: {
id: 42,
remote_url:
"git@github.com:discourse-org/discourse-incomplete-theme.git",
commits_behind: 1,
},
}),
];
this.setProperties({
themes,
currentTab: THEMES,
});
await render(
hbs`<ThemesList @themes={{this.themes}} @components={{(array)}} @currentTab={{this.currentTab}} />`
);
assert.ok(exists(".themes-list-container__filter-input"));
assert.deepEqual(
[...queryAll(".themes-list-container__item .info .name")].map((node) =>
node.textContent.trim()
),
[
"Theme enabled 1",
"Theme enabled 2",
"Theme disabled 1",
"Theme disabled 2",
]
);
await selectKit(".themes-list-container__filter-input").expand();
await selectKit(".themes-list-container__filter-input").selectRowByValue(
"active"
);
assert.deepEqual(
[...queryAll(".themes-list-container__item .info .name")].map((node) =>
node.textContent.trim()
),
["Theme enabled 1", "Theme enabled 2"]
);
await selectKit(".themes-list-container__filter-input").expand();
await selectKit(".themes-list-container__filter-input").selectRowByValue(
"inactive"
);
assert.deepEqual(
[...queryAll(".themes-list-container__item .info .name")].map((node) =>
node.textContent.trim()
),
["Theme disabled 1", "Theme disabled 2"]
);
await selectKit(".themes-list-container__filter-input").expand();
await selectKit(".themes-list-container__filter-input").selectRowByValue(
"updates_available"
);
assert.deepEqual(
[...queryAll(".themes-list-container__item .info .name")].map((node) =>
node.textContent.trim()
),
["Theme disabled 2"]
);
});
test("switching between themes and components tabs keeps the search visible only if both tabs have at least 10 items", async function (assert) {
const themes = createThemes(10, (n) => {
return { name: `Theme ${n}${n}` };
});
@ -212,20 +289,20 @@ module("Integration | Component | themes-list", function (hooks) {
hbs`<ThemesList @themes={{this.themes}} @components={{this.components}} @currentTab={{this.currentTab}} />`
);
await fillIn(".themes-list-filter .filter-input", "11");
await fillIn(".themes-list-container__search-input", "11");
assert.strictEqual(
query(".themes-list-container").textContent.trim(),
query(".themes-list-container__item .info").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" +
!exists(".themes-list-container__search-input"),
"search 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) =>
[...queryAll(".themes-list-container__item .info .name")].map((node) =>
node.textContent.trim()
),
[
@ -253,22 +330,22 @@ module("Integration | Component | themes-list", function (hooks) {
)
);
assert.ok(
exists(".themes-list-filter"),
"filter is now shown for the components tab"
exists(".themes-list-container__search-input"),
"search is now shown for the components tab"
);
await fillIn(".themes-list-filter .filter-input", "66");
await fillIn(".themes-list-container__search-input", "66");
assert.strictEqual(
query(".themes-list-container").textContent.trim(),
query(".themes-list-container__item .info").textContent.trim(),
"Component 66",
"only 1 component is shown"
);
await click(".themes-list-header .themes-tab");
assert.strictEqual(
query(".themes-list-container").textContent.trim(),
query(".themes-list-container__item .info").textContent.trim(),
"Theme 66",
"filter term persisted between tabs because both have more than 10 items"
"search term persisted between tabs because both have more than 10 items"
);
});
});

View File

@ -246,7 +246,6 @@
}
.themes-list-container {
overflow-y: auto;
box-sizing: border-box;
max-height: 60vh;
border-bottom-right-radius: var(--d-border-radius);
@ -262,10 +261,10 @@
border-left: 1px solid var(--primary-low);
width: 100%;
.themes-list-item:last-child {
&__item:last-child {
border-bottom: none;
}
.themes-list-item {
&__item {
color: var(--primary);
border-bottom: 1px solid var(--primary-low);
display: flex;
@ -391,34 +390,52 @@
width: 100%;
}
}
&__filter {
padding-left: 0.67em;
display: flex;
height: 3em;
align-items: center;
background-color: var(--primary-very-low);
}
.themes-list-filter {
&__filter-label {
white-space: nowrap;
margin-right: 1em;
}
&__filter-input {
margin-right: 0.5em;
summary {
width: auto;
}
}
&__search {
display: flex;
align-items: center;
position: sticky;
top: 0;
background: var(--secondary);
z-index: z("base");
height: 3em;
background: var(--primary-very-low);
.d-icon {
position: absolute;
padding-left: 0.5em;
}
}
&__search-input {
width: 100%;
height: 100%;
margin: 0;
border: 0;
padding-left: 2em;
background-color: var(--primary-very-low);
.filter-input {
width: 100%;
height: 100%;
margin: 0;
border: 0;
padding-left: 2em;
&:focus {
outline: 0;
&:focus {
outline: 0;
~ .d-icon {
color: var(--tertiary-hover);
}
~ .d-icon {
color: var(--tertiary-hover);
}
}
}
@ -483,7 +500,7 @@
left: 0;
right: 0;
z-index: z("fullscreen");
background-color: var(--secondary);
background: var(--secondary);
width: 100%;
padding: 0;
margin: 0;

View File

@ -5278,12 +5278,17 @@ en:
body: "Body"
revert: "Revert Changes"
revert_confirm: "Are you sure you want to revert your changes?"
component:
all_filter: "All"
enabled_filter: "Enabled"
disabled_filter: "Disabled"
updates_available_filter: "Updates Available"
theme:
filter_by: "Filter by"
theme: "Theme"
component: "Component"
components: "Components"
filter_placeholder: "type to filter…"
search_placeholder: "type to search…"
theme_name: "Theme name"
component_name: "Component name"
themes_intro: "Select an existing theme or install a new one to get started"
@ -5457,6 +5462,10 @@ en:
title: "Define theme settings in YAML format"
scss_color_variables_warning: 'Using core SCSS color variables in themes is deprecated. Please use CSS custom properties instead. See <a href="https://meta.discourse.org/t/-/77551#color-variables-2" target="_blank">this guide</a> for more details.'
scss_warning_inline: "Using core SCSS color variables in themes is deprecated."
all_filter: "All"
active_filter: "Active"
inactive_filter: "Inactive"
updates_available_filter: "Updates Available"
colors:
select_base:
title: "Select base color palette"

View File

@ -70,6 +70,10 @@ en:
inline_oneboxer:
topic_page_title_post_number: "#%{post_number}"
topic_page_title_post_number_by_user: "#%{post_number} by %{username}"
components:
enabled_filter: "Enabled"
disabled_filter: "Disabled"
updates_available_filter: "Updates Available"
themes:
bad_color_scheme: "Can not update theme, invalid color palette"
other_error: "Something went wrong updating theme"