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; const MAX_COMPONENTS = 4;
@classNames("themes-list-item") @classNames("themes-list-container__item")
@classNameBindings("theme.selected:selected") @classNameBindings("theme.selected:selected")
export default class ThemesListItem extends Component { export default class ThemesListItem extends Component {
childrenExpanded = false; childrenExpanded = false;

View File

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

View File

@ -5,8 +5,42 @@ import { inject as service } from "@ember/service";
import { classNames } from "@ember-decorators/component"; import { classNames } from "@ember-decorators/component";
import DeleteThemesConfirm from "discourse/components/modal/delete-themes-confirm"; import DeleteThemesConfirm from "discourse/components/modal/delete-themes-confirm";
import discourseComputed, { bind } from "discourse-common/utils/decorators"; import discourseComputed, { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import { COMPONENTS, THEMES } from "admin/models/theme"; 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") @classNames("themes-list")
export default class ThemesList extends Component { export default class ThemesList extends Component {
@service router; @service router;
@ -14,7 +48,8 @@ export default class ThemesList extends Component {
THEMES = THEMES; THEMES = THEMES;
COMPONENTS = COMPONENTS; COMPONENTS = COMPONENTS;
filterTerm = null; searchTerm = null;
filter = ALL_FILTER;
selectInactiveMode = false; selectInactiveMode = false;
@gt("themesList.length", 0) hasThemes; @gt("themesList.length", 0) hasThemes;
@ -23,12 +58,15 @@ export default class ThemesList extends Component {
@gt("inactiveThemes.length", 0) hasInactiveThemes; @gt("inactiveThemes.length", 0) hasInactiveThemes;
@gte("themesList.length", 10) showFilter; @gte("themesList.length", 10) showSearch;
@equal("currentTab", THEMES) themesTabActive; @equal("currentTab", THEMES) themesTabActive;
@equal("currentTab", COMPONENTS) componentsTabActive; @equal("currentTab", COMPONENTS) componentsTabActive;
@equal("filter", ACTIVE_FILTER) activeFilter;
@equal("filter", INACTIVE_FILTER) inactiveFilter;
@discourseComputed("themes", "components", "currentTab") @discourseComputed("themes", "components", "currentTab")
themesList(themes, components) { themesList(themes, components) {
if (this.themesTabActive) { 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( @discourseComputed(
"themesList", "themesList",
"currentTab", "currentTab",
"themesList.@each.user_selectable", "themesList.@each.user_selectable",
"themesList.@each.default", "themesList.@each.default",
"themesList.@each.markedToDelete", "themesList.@each.markedToDelete",
"filterTerm" "searchTerm",
"filter"
) )
inactiveThemes(themes) { inactiveThemes(themes) {
let results; let results;
@ -57,7 +105,10 @@ export default class ThemesList extends Component {
(theme) => !theme.get("user_selectable") && !theme.get("default") (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") @discourseComputed("themesList.@each.markedToDelete")
@ -75,7 +126,8 @@ export default class ThemesList extends Component {
"currentTab", "currentTab",
"themesList.@each.user_selectable", "themesList.@each.user_selectable",
"themesList.@each.default", "themesList.@each.default",
"filterTerm" "searchTerm",
"filter"
) )
activeThemes(themes) { activeThemes(themes) {
let results; let results;
@ -96,7 +148,10 @@ export default class ThemesList extends Component {
.localeCompare(b.get("name").toLowerCase()); .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") @discourseComputed("themesList.@each.markedToDelete")
someInactiveSelected() { someInactiveSelected() {
@ -111,7 +166,7 @@ export default class ThemesList extends Component {
return this.selectedCount === this.inactiveThemes.length; return this.selectedCount === this.inactiveThemes.length;
} }
_filterThemes(themes, term) { _searchThemes(themes, term) {
term = term?.trim()?.toLowerCase(); term = term?.trim()?.toLowerCase();
if (!term) { if (!term) {
return themes; return themes;
@ -131,8 +186,9 @@ export default class ThemesList extends Component {
if (newTab !== this.currentTab) { if (newTab !== this.currentTab) {
this.set("selectInactiveMode", false); this.set("selectInactiveMode", false);
this.set("currentTab", newTab); this.set("currentTab", newTab);
if (!this.showFilter) { this.set("filter", ALL_FILTER);
this.set("filterTerm", null); if (!this.showSearch) {
this.set("searchTerm", null);
} }
} }
} }

View File

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

View File

@ -8,6 +8,7 @@ import {
query, query,
queryAll, queryAll,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
import Theme, { COMPONENTS, THEMES } from "admin/models/theme"; import Theme, { COMPONENTS, THEMES } from "admin/models/theme";
@ -59,14 +60,18 @@ module("Integration | Component | themes-list", function (hooks) {
exists(".inactive-indicator"), exists(".inactive-indicator"),
"there is no inactive themes separator when all themes are inactive" "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)); [2, 3].forEach((num) => this.themes[num].set("user_selectable", true));
this.themes[4].set("default", true); this.themes[4].set("default", true);
this.set("themes", this.themes); 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 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( assert.deepEqual(
Array.from(queryAll(".themes-list-item .name")).map((node) => [...queryAll(".themes-list-container__item .info .name")].map((node) =>
node.innerText.trim() node.innerText.trim()
), ),
names, names,
@ -74,7 +79,7 @@ module("Integration | Component | themes-list", function (hooks) {
); );
assert.strictEqual( assert.strictEqual(
queryAll(".inactive-indicator").index(), queryAll(".inactive-indicator").index(),
3, 4,
"the separator is in the right location" "the separator is in the right location"
); );
@ -87,12 +92,12 @@ module("Integration | Component | themes-list", function (hooks) {
this.set("themes", []); this.set("themes", []);
assert.strictEqual( assert.strictEqual(
count(".themes-list-item"), count(".themes-list-container__item .empty"),
1, 1,
"shows one entry with a message when there is nothing to display" "shows one entry with a message when there is nothing to display"
); );
assert.strictEqual( 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"), I18n.t("admin.customize.theme.empty"),
"displays the right message" "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.notOk(exists(".inactive-indicator"), "there is no separator");
assert.strictEqual( assert.strictEqual(
count(".themes-list-item"), count(".themes-list-container__item .info"),
5, 5,
"displays all components" "displays all components"
); );
this.set("components", []); this.set("components", []);
assert.strictEqual( assert.strictEqual(
count(".themes-list-item"), count(".themes-list-container__item .empty"),
1, 1,
"shows one entry with a message when there is nothing to display" "shows one entry with a message when there is nothing to display"
); );
assert.strictEqual( 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"), I18n.t("admin.customize.theme.empty"),
"displays the right message" "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); const themes = createThemes(9);
this.setProperties({ this.setProperties({
themes, themes,
@ -161,12 +166,12 @@ module("Integration | Component | themes-list", function (hooks) {
); );
assert.ok( assert.ok(
!exists(".themes-list-filter"), !exists(".themes-list-search"),
"filter input not shown when we have fewer than 10 themes" "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"] const themes = ["osama", "OsAmaa", "osAMA 1234"]
.map((name) => Theme.create({ name: `Theme ${name}` })) .map((name) => Theme.create({ name: `Theme ${name}` }))
.concat(createThemes(7)); .concat(createThemes(7));
@ -179,18 +184,90 @@ module("Integration | Component | themes-list", function (hooks) {
hbs`<ThemesList @themes={{this.themes}} @components={{(array)}} @currentTab={{this.currentTab}} />` hbs`<ThemesList @themes={{this.themes}} @components={{(array)}} @currentTab={{this.currentTab}} />`
); );
assert.ok(exists(".themes-list-filter")); assert.ok(exists(".themes-list-container__search-input"));
await fillIn(".themes-list-filter .filter-input", " oSAma "); await fillIn(".themes-list-container__search-input", " oSAma ");
assert.deepEqual( assert.deepEqual(
Array.from(queryAll(".themes-list-item .name")).map((node) => [...queryAll(".themes-list-container__item .info .name")].map((node) =>
node.textContent.trim() node.textContent.trim()
), ),
["Theme osama", "Theme OsAmaa", "Theme osAMA 1234"], ["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) => { const themes = createThemes(10, (n) => {
return { name: `Theme ${n}${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}} />` 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( assert.strictEqual(
query(".themes-list-container").textContent.trim(), query(".themes-list-container__item .info").textContent.trim(),
"Theme 11", "Theme 11",
"only 1 theme is shown" "only 1 theme is shown"
); );
await click(".themes-list-header .components-tab"); await click(".themes-list-header .components-tab");
assert.ok( assert.ok(
!exists(".themes-list-filter"), !exists(".themes-list-container__search-input"),
"filter input/term do not persist when we switch to the other" + "search input/term do not persist when we switch to the other" +
" tab because it has fewer than 10 items" " tab because it has fewer than 10 items"
); );
assert.deepEqual( assert.deepEqual(
Array.from(queryAll(".themes-list-item .name")).map((node) => [...queryAll(".themes-list-container__item .info .name")].map((node) =>
node.textContent.trim() node.textContent.trim()
), ),
[ [
@ -253,22 +330,22 @@ module("Integration | Component | themes-list", function (hooks) {
) )
); );
assert.ok( assert.ok(
exists(".themes-list-filter"), exists(".themes-list-container__search-input"),
"filter is now shown for the components tab" "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( assert.strictEqual(
query(".themes-list-container").textContent.trim(), query(".themes-list-container__item .info").textContent.trim(),
"Component 66", "Component 66",
"only 1 component is shown" "only 1 component is shown"
); );
await click(".themes-list-header .themes-tab"); await click(".themes-list-header .themes-tab");
assert.strictEqual( assert.strictEqual(
query(".themes-list-container").textContent.trim(), query(".themes-list-container__item .info").textContent.trim(),
"Theme 66", "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 { .themes-list-container {
overflow-y: auto;
box-sizing: border-box; box-sizing: border-box;
max-height: 60vh; max-height: 60vh;
border-bottom-right-radius: var(--d-border-radius); border-bottom-right-radius: var(--d-border-radius);
@ -262,10 +261,10 @@
border-left: 1px solid var(--primary-low); border-left: 1px solid var(--primary-low);
width: 100%; width: 100%;
.themes-list-item:last-child { &__item:last-child {
border-bottom: none; border-bottom: none;
} }
.themes-list-item { &__item {
color: var(--primary); color: var(--primary);
border-bottom: 1px solid var(--primary-low); border-bottom: 1px solid var(--primary-low);
display: flex; display: flex;
@ -391,34 +390,52 @@
width: 100%; 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; display: flex;
align-items: center; align-items: center;
position: sticky; position: sticky;
top: 0; top: 0;
background: var(--secondary);
z-index: z("base"); z-index: z("base");
height: 3em; height: 3em;
background: var(--primary-very-low);
.d-icon { .d-icon {
position: absolute; position: absolute;
padding-left: 0.5em; 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 { &:focus {
width: 100%; outline: 0;
height: 100%;
margin: 0;
border: 0;
padding-left: 2em;
&:focus { ~ .d-icon {
outline: 0; color: var(--tertiary-hover);
~ .d-icon {
color: var(--tertiary-hover);
}
} }
} }
} }
@ -483,7 +500,7 @@
left: 0; left: 0;
right: 0; right: 0;
z-index: z("fullscreen"); z-index: z("fullscreen");
background-color: var(--secondary); background: var(--secondary);
width: 100%; width: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;

View File

@ -5278,12 +5278,17 @@ en:
body: "Body" body: "Body"
revert: "Revert Changes" revert: "Revert Changes"
revert_confirm: "Are you sure you want to revert your 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: theme:
filter_by: "Filter by"
theme: "Theme" theme: "Theme"
component: "Component" component: "Component"
components: "Components" components: "Components"
filter_placeholder: "type to filter…" search_placeholder: "type to search…"
theme_name: "Theme name" theme_name: "Theme name"
component_name: "Component name" component_name: "Component name"
themes_intro: "Select an existing theme or install a new one to get started" 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" 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_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." 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: colors:
select_base: select_base:
title: "Select base color palette" title: "Select base color palette"

View File

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