FEATURE: Introduce new components listing page (#32164)

Follow-up to https://github.com/discourse/discourse/pull/31887

This commit introduces a new design for the components listing page, which
is not linked from anywhere in the UI at the moment, but it can be
accessed by heading to the `/admin/config/customize/components` path
directly. We'll make this new design available from the sidebar and
remove the old page once we've tested and validated the new design
internally.

Internal topic: t/146007.

---------

Co-authored-by: Ella <ella.estigoy@gmail.com>
This commit is contained in:
Osama Sayegh
2025-04-08 17:58:29 +03:00
committed by GitHub
co-authored by Ella
parent e84083ee36
commit ad0966afa9
21 changed files with 1362 additions and 73 deletions
@@ -1,17 +1,66 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { array, concat, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader";
import DSelect from "discourse/components/d-select";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import DropdownMenu from "discourse/components/dropdown-menu";
import FilterInput from "discourse/components/filter-input";
import icon from "discourse/helpers/d-icon";
import { ajax } from "discourse/lib/ajax";
import { extractErrorInfo } from "discourse/lib/ajax-error";
import discourseDebounce from "discourse/lib/debounce";
import { INPUT_DELAY } from "discourse/lib/environment";
import getURL from "discourse/lib/get-url";
import { i18n } from "discourse-i18n";
import InstallThemeCard from "admin/components/admin-config-area-cards/install-theme-card";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import InstallComponentModal from "admin/components/modal/install-theme";
import ThemesGrid from "admin/components/themes-grid";
import { COMPONENTS } from "admin/models/theme";
import DMenu from "float-kit/components/d-menu";
const STATUS_FILTER_OPTIONS = [
{
value: "all",
label: "admin.config_areas.themes_and_components.components.filter_by_all",
},
{
value: "active",
label:
"admin.config_areas.themes_and_components.components.filter_by_active",
},
{
value: "inactive",
label:
"admin.config_areas.themes_and_components.components.filter_by_inactive",
},
{
value: "updates_available",
label:
"admin.config_areas.themes_and_components.components.filter_by_updates_available",
},
];
export default class AdminConfigAreasComponents extends Component {
@service modal;
@service router;
@service toasts;
@tracked loading = true;
@tracked components = [];
@tracked nameFilter;
@tracked statusFilter;
@tracked hasComponents = false;
constructor() {
super(...arguments);
this.load();
}
@action
installModal() {
this.modal.show(InstallComponentModal, {
@@ -28,7 +77,7 @@ export default class AdminConfigAreasComponents extends Component {
selectedType: COMPONENTS,
userId: null,
content: [],
installedThemes: this.args.components,
installedThemes: this.components,
addTheme: this.addComponent,
updateSelectedType: () => {},
showComponentsOnly: true,
@@ -45,19 +94,444 @@ export default class AdminConfigAreasComponents extends Component {
},
duration: 2000,
});
this.router.refresh();
this.load();
}
@action
onNameFilterChange(event) {
this.loading = true;
this.nameFilter = event.target.value;
discourseDebounce(this, this.load, INPUT_DELAY);
}
@action
onStatusFilterChange(value) {
this.statusFilter = value;
this.load();
}
@action
async load() {
this.loading = true;
try {
const data = await ajax("/admin/config/customize/components", {
data: { name: this.nameFilter, status: this.statusFilter },
});
this.components = data.components;
if (!this.hasComponents && !this.nameFilter && !this.statusFilter) {
this.hasComponents = !!data.components.length;
}
} finally {
this.loading = false;
}
}
<template>
<div class="admin-detail">
<ThemesGrid @themes={{@components}}>
<:specialCard>
<InstallThemeCard
@component={{true}}
@openModal={{this.installModal}}
/>
</:specialCard>
</ThemesGrid>
<DPageSubheader
@titleLabel={{i18n
"admin.config_areas.themes_and_components.components.title"
}}
@descriptionLabel={{i18n
"admin.config_areas.themes_and_components.components.description"
}}
>
<:actions as |actions|>
<actions.Primary
disabled={{this.loading}}
@label="admin.config_areas.themes_and_components.components.install"
@action={{this.installModal}}
/>
</:actions>
</DPageSubheader>
<div class="container">
{{#if this.hasComponents}}
<div class="d-admin-filter">
<div
class="admin-filter__input-container admin-config-components__name-filter"
>
<FilterInput
placeholder={{i18n
"admin.config_areas.themes_and_components.components.search_components"
}}
@filterAction={{this.onNameFilterChange}}
class="admin-filter__input"
/>
</div>
<label class="admin-config-components__status-filter">
{{i18n
"admin.config_areas.themes_and_components.components.filter_by"
}}
<DSelect
@value="all"
@includeNone={{false}}
@onChange={{this.onStatusFilterChange}}
as |select|
>
{{#each STATUS_FILTER_OPTIONS as |option|}}
<select.Option @value={{option.value}}>
{{i18n option.label}}
</select.Option>
{{/each}}
</DSelect>
</label>
</div>
{{/if}}
<ConditionalLoadingSpinner @condition={{this.loading}}>
{{#if this.components.length}}
<table class="d-admin-table component-list">
<thead>
<th>{{i18n
"admin.config_areas.themes_and_components.components.name"
}}</th>
<th>{{i18n
"admin.config_areas.themes_and_components.components.used_on"
}}</th>
<th>{{i18n
"admin.config_areas.themes_and_components.components.enabled"
}}</th>
<th></th>
</thead>
<tbody>
{{#each this.components as |comp|}}
<ComponentRow @component={{comp}} @refresh={{this.load}} />
{{/each}}
</tbody>
</table>
{{else}}
{{#if this.hasComponents}}
{{i18n
"admin.config_areas.themes_and_components.components.no_components_found"
}}
{{else}}
<AdminConfigAreaEmptyList
@emptyLabel="admin.config_areas.themes_and_components.components.no_components"
/>
{{/if}}
{{/if}}
</ConditionalLoadingSpinner>
</div>
</template>
}
class ComponentRow extends Component {
@service toasts;
@service dialog;
@tracked enabled = this.args.component.enabled;
@tracked hasUpdates = this.args.component.remote_theme?.commits_behind > 0;
@tracked disableToggle = false;
@tracked checkingForUpdates = false;
@tracked updating = false;
get parentThemesCell() {
const names = this.args.component.parent_themes.map((theme) => theme.name);
names.sort();
if (!names.length) {
return;
} else if (names.length === 1) {
return names[0];
} else if (names.length === 2) {
return i18n(
"admin.config_areas.themes_and_components.components.parent_themes_two",
{
name1: names[0],
name2: names[1],
}
);
} else if (names.length === 3) {
return i18n(
"admin.config_areas.themes_and_components.components.parent_themes_three",
{
name1: names[0],
name2: names[1],
name3: names[2],
}
);
} else {
return i18n(
"admin.config_areas.themes_and_components.components.parent_themes_more_than_three",
{
name1: names[0],
name2: names[1],
name3: names[2],
count: names.length - 3,
}
);
}
}
@action
async toggleEnabled() {
this.disableToggle = true;
try {
const data = await this.save({ enabled: !this.enabled });
this.enabled = data.theme.enabled;
} finally {
this.disableToggle = false;
}
}
@action
async checkForUpdates() {
this.checkingForUpdates = true;
try {
const data = await this.save({ remote_check: true });
if (data.theme.remote_theme.commits_behind > 0) {
this.hasUpdates = true;
this.toasts.default({
duration: 5000,
data: {
message: i18n(
"admin.config_areas.themes_and_components.components.new_update_for_component",
{ name: this.args.component.name }
),
},
});
} else {
this.hasUpdates = false;
this.toasts.default({
duration: 5000,
data: {
message: i18n(
"admin.config_areas.themes_and_components.components.component_up_to_date",
{ name: this.args.component.name }
),
},
});
}
} finally {
this.checkingForUpdates = false;
}
}
@action
async updateToLatest() {
this.updating = true;
try {
await this.save({ remote_update: true });
this.hasUpdates = false;
this.toasts.success({
duration: 5000,
data: {
message: i18n(
"admin.config_areas.themes_and_components.components.updated_successfully",
{ name: this.args.component.name }
),
},
});
} finally {
this.updating = false;
}
}
@action
delete() {
return this.dialog.yesNoConfirm({
message: i18n(
"admin.config_areas.themes_and_components.components.delete_confirm",
{ name: this.args.component.name }
),
didConfirm: async () => {
try {
await ajax(`/admin/themes/${this.args.component.id}`, {
type: "DELETE",
});
this.toasts.success({
duration: 5000,
data: {
message: i18n(
"admin.config_areas.themes_and_components.components.deleted_successfully",
{ name: this.args.component.name }
),
},
});
this.args.refresh();
} catch (error) {
this.toasts.error({
duration: 5000,
data: {
message: extractErrorInfo(error),
},
});
}
},
});
}
async save(attrs) {
try {
return await ajax(`/admin/themes/${this.args.component.id}.json`, {
type: "PUT",
data: {
theme: attrs,
},
});
} catch (error) {
this.toasts.error({
duration: 5000,
data: {
message: extractErrorInfo(error),
},
});
throw error;
}
}
<template>
<tr
data-component-id={{@component.id}}
class="d-admin-row__content admin-config-components__component-row
{{if this.hasUpdates 'has-update'}}"
>
<td class="d-admin-row__overview">
<div class="d-admin-row__overview-name">
{{@component.name}}
</div>
{{#if @component.remote_theme.authors}}
<div
class="d-admin-row__overview-author admin-config-components__author-name"
>{{i18n
"admin.config_areas.themes_and_components.components.by_author"
(hash name=@component.remote_theme.authors)
}}</div>
{{/if}}
{{#if @component.description}}
<div
class="d-admin-row__overview-about admin-config-components__description"
>
{{@component.description}}
{{#if @component.remote_theme.about_url}}
<a href={{@component.remote_theme.about_url}}>{{i18n
"admin.config_areas.themes_and_components.components.learn_more"
}}
{{icon "up-right-from-square"}}
</a>
{{/if}}
</div>
{{/if}}
{{#if this.hasUpdates}}
<div
class="d-admin-row__overview-about admin-config-components__update-available"
>
{{i18n
"admin.config_areas.themes_and_components.components.update_available"
}}
</div>
{{/if}}
</td>
<td class="d-admin-row__detail admin-config-components__parent-themes">
<div class="d-admin-row__mobile-label">
{{i18n "admin.config_areas.themes_and_components.components.used_on"}}
</div>
<div class="admin-config-components__parent-themes-list">
{{#if @component.parent_themes.length}}
{{this.parentThemesCell}}
{{else}}
<div class="status-label --inactive">
<div class="status-label-indicator"></div>
<div class="status-label-text">
{{i18n
"admin.config_areas.themes_and_components.components.badge_inactive"
}}
</div>
</div>
{{/if}}
</div>
</td>
<td class="d-admin-row__detail">
<div class="d-admin-row__mobile-label">
{{i18n "admin.config_areas.themes_and_components.components.enabled"}}
</div>
<DToggleSwitch
@state={{this.enabled}}
class="admin-config-components__toggle"
disabled={{this.disableToggle}}
{{on "click" this.toggleEnabled}}
/>
</td>
<td class="d-admin-row__controls">
<div class="d-admin-row__controls-options">
<DButton
class="admin-config-components__edit"
@label="admin.config_areas.themes_and_components.components.edit"
@route="adminCustomizeThemes.show"
@routeModels={{array "themes" @component.id}}
/>
<DMenu
@identifier="component-menu"
@title={{i18n "admin.config_areas.flags.more_options.title"}}
@icon="ellipsis"
@class="btn-default admin-config-components__more-actions"
>
<:content>
<DropdownMenu as |dropdown|>
<dropdown.item>
<DButton
class="btn-transparent admin-config-components__preview"
target="_blank"
rel="noopener noreferrer"
@label="admin.config_areas.themes_and_components.components.preview"
@icon="desktop"
@href={{getURL
(concat "/admin/themes/" @component.id "/preview")
}}
/>
</dropdown.item>
{{#if @component.remote_theme.is_git}}
<dropdown.item>
{{#if this.hasUpdates}}
<DButton
class="btn-transparent admin-config-components__update"
@label="admin.config_areas.themes_and_components.components.update"
@icon="cloud-arrow-down"
@action={{this.updateToLatest}}
@isLoading={{this.updating}}
/>
{{else}}
<DButton
class="btn-transparent admin-config-components__check-updates"
@label="admin.config_areas.themes_and_components.components.check_update"
@icon="arrows-rotate"
@action={{this.checkForUpdates}}
@isLoading={{this.checkingForUpdates}}
/>
{{/if}}
</dropdown.item>
{{/if}}
<dropdown.item>
<DButton
class="btn-transparent admin-config-components__export"
target="_blank"
rel="noopener noreferrer"
@label="admin.config_areas.themes_and_components.components.export"
@icon="download"
@href={{getURL
(concat
"/admin/customize/themes/" @component.id "/export"
)
}}
/>
</dropdown.item>
<dropdown.item>
<DButton
class="btn-danger admin-config-components__delete"
@label="admin.config_areas.themes_and_components.components.delete"
@icon="trash-can"
@action={{this.delete}}
/>
</dropdown.item>
</DropdownMenu>
</:content>
</DMenu>
</div>
</td>
</tr>
</template>
}
@@ -2,11 +2,6 @@ import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
export default class AdminConfigThemesAndComponentsComponentsRoute extends DiscourseRoute {
async model() {
const components = await this.store.findAll("theme");
return components.reject((t) => !t.component);
}
titleToken() {
return i18n("admin.config_areas.themes_and_components.components.title");
}
@@ -12,6 +12,6 @@ export default RouteTemplate(
}}
/>
<Components @components={{@controller.model}} />
<Components />
</template>
);
@@ -1278,3 +1278,4 @@ a.inline-editable-field {
@import "admin/admin_bulk_users_delete_modal";
@import "admin/color-palette-editor";
@import "admin/admin_config_color_palettes";
@import "admin/admin_config_components";
@@ -0,0 +1,39 @@
.admin-config.components {
.admin-config-components {
&__filters {
display: flex;
justify-content: space-between;
}
&__status-filter {
display: flex;
white-space: nowrap;
align-items: center;
gap: 1em;
}
&__parent-themes-list {
@include breakpoint("tablet") {
text-align: right;
max-width: 60%;
}
}
&__update-available {
font-size: var(--font-down-1);
font-weight: bold;
margin: var(--space-1) 0 var(--space-1) 0;
}
}
}
.d-admin-table.component-list {
.has-update {
background-color: var(--tertiary-very-low);
border-left: solid 3px var(--tertiary);
}
.d-admin-row__overview-about .d-icon {
font-size: var(--font-down-3);
}
}
@@ -2,6 +2,11 @@
background-color: var(--primary-very-low);
padding: var(--space-2);
display: flex;
gap: var(--space-2);
@include breakpoint("tablet") {
flex-direction: column;
}
}
.admin-filter__input-container {
@@ -5,5 +5,27 @@ class Admin::Config::CustomizeController < Admin::AdminController
end
def components
components = Theme.include_basic_relations.where(component: true).order(:name)
name_search_term = params[:name].presence&.strip
if name_search_term
components = components.where("themes.name ILIKE ?", "%#{name_search_term}%")
end
status_filter = params[:status].presence
if status_filter
case status_filter
when "active"
components = components.joins(:parent_themes).distinct
when "inactive"
components = components.left_joins(:parent_themes).where(parent_themes: { id: nil })
when "updates_available"
components = components.joins(:remote_theme).where(remote_theme: { commits_behind: 1.. })
else
raise Discourse::InvalidParameters if status_filter != "all"
end
end
render json: { components: serialize_data(components, ComponentIndexSerializer) }
end
end
+3 -4
View File
@@ -83,20 +83,19 @@ class Theme < ActiveRecord::Base
scope :include_relations,
-> do
includes(
include_basic_relations.includes(
:child_themes,
:parent_themes,
:remote_theme,
:theme_settings,
:settings_field,
:locale_fields,
:user,
:color_scheme,
:theme_translation_overrides,
theme_fields: %i[upload theme_settings_migration],
)
end
scope :include_basic_relations, -> { includes(:parent_themes, :remote_theme, :user) }
delegate :remote_url, to: :remote_theme, private: true, allow_nil: true
def notify_color_change(color, scheme: nil)
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class ComponentIndexSerializer < BasicThemeSerializer
attributes :remote_theme_id, :supported?, :enabled?, :disabled_at
has_one :user, serializer: UserNameSerializer, embed: :object
has_one :disabled_by, serializer: UserNameSerializer, embed: :object
has_many :parent_themes, serializer: BasicThemeSerializer, embed: :objects
has_one :remote_theme, serializer: RemoteThemeSerializer, embed: :objects
def parent_themes
object.parent_themes
end
def include_disabled_at?
object.component? && !object.enabled?
end
def include_disabled_by?
include_disabled_at?
end
end
+35
View File
@@ -6201,9 +6201,44 @@ en:
back: "Back to themes"
components:
title: "Components"
description: "Customizations that change surface elements of your forum design, or add extra front-end features"
components_intro: "Install a new component to get started, or create your own from scratch using these resources."
new_component: "New component"
back: "Back to components"
install: "Install"
name: "Name"
used_on: "Used on"
enabled: "Enabled?"
by_author: "By %{name}"
learn_more: "Learn more"
edit: "Edit"
parent_themes_two: "%{name1} and %{name2}"
parent_themes_three: "%{name1}, %{name2} and %{name3}"
parent_themes_more_than_three:
one: "%{name1}, %{name2}, %{name3} and %{count} more"
other: "%{name1}, %{name2}, %{name3} and %{count} more"
add_to_theme: "Add to theme"
preview: "Preview"
update: "Update to latest"
check_update: "Check for updates"
update_available: "Update available!"
export: "Export"
convert: "Convert"
delete: "Delete"
filter_by: "Filter by"
filter_by_all: "All"
filter_by_active: "Active"
filter_by_inactive: "Inactive"
filter_by_updates_available: "Updates available"
search_components: "Type a component name"
new_update_for_component: "New update available for %{name}!"
component_up_to_date: '"%{name}" is up to date.'
updated_successfully: '"%{name}" has been updated successfully.'
delete_confirm: 'Are you sure you want to delete "%{name}"?'
deleted_successfully: '"%{name}" has been deleted successfully.'
no_components: "No components installed."
no_components_found: "No components match your filters."
badge_inactive: "Unused"
user_fields:
field: "Field"
type: "Type"
+1
View File
@@ -69,6 +69,7 @@ module SvgSprite
circle-xmark
clock
clock-rotate-left
cloud-arrow-down
cloud-arrow-up
code
comment
@@ -0,0 +1,106 @@
# frozen_string_literal: true
RSpec.describe Admin::Config::CustomizeController do
fab!(:admin)
fab!(:parent_theme_1) { Fabricate(:theme) }
fab!(:parent_theme_2) { Fabricate(:theme) }
fab!(:active_component) do
Fabricate(
:theme,
name: "AweSome comp",
component: true,
parent_themes: [parent_theme_1, parent_theme_2],
)
end
fab!(:inactive_component) { Fabricate(:theme, name: "some comp", component: true) }
fab!(:remote_component) do
Fabricate(
:theme,
component: true,
remote_theme: RemoteTheme.create!(remote_url: "https://github.com/discourse/discourse-tc"),
)
end
fab!(:remote_component_with_update) do
Fabricate(
:theme,
component: true,
remote_theme:
RemoteTheme.create!(
remote_url: "https://github.com/discourse/discourse",
commits_behind: 1,
),
)
end
before { sign_in(admin) }
describe "#components" do
context "when filtering by `active`" do
it "returns components that have a parent theme" do
get "/admin/config/customize/components.json", params: { status: "active" }
expect(response.status).to eq(200)
expect(response.parsed_body["components"].map { |c| c["id"] }).to contain_exactly(
active_component.id,
)
end
end
context "when filtering by `inactive`" do
it "returns components that have no parent theme" do
get "/admin/config/customize/components.json", params: { status: "inactive" }
expect(response.status).to eq(200)
expect(response.parsed_body["components"].map { |c| c["id"] }).to contain_exactly(
inactive_component.id,
remote_component.id,
remote_component_with_update.id,
)
end
end
context "when filtering by `updates_available`" do
it "returns components that are behind their remote" do
get "/admin/config/customize/components.json", params: { status: "updates_available" }
expect(response.status).to eq(200)
expect(response.parsed_body["components"].map { |c| c["id"] }).to contain_exactly(
remote_component_with_update.id,
)
end
end
context "when filtering by `all`" do
it "returns all components" do
get "/admin/config/customize/components.json", params: { status: "all" }
expect(response.status).to eq(200)
expect(response.parsed_body["components"].map { |c| c["id"] }).to contain_exactly(
active_component.id,
inactive_component.id,
remote_component.id,
remote_component_with_update.id,
)
end
end
context "when there's no filter param" do
it "is equivalent to filtering by `all`" do
get "/admin/config/customize/components.json"
expect(response.status).to eq(200)
expect(response.parsed_body["components"].map { |c| c["id"] }).to contain_exactly(
active_component.id,
inactive_component.id,
remote_component.id,
remote_component_with_update.id,
)
end
end
it "can filter components by a search term" do
get "/admin/config/customize/components.json", params: { name: "SomE" }
expect(response.status).to eq(200)
expect(response.parsed_body["components"].map { |c| c["id"] }).to contain_exactly(
active_component.id,
inactive_component.id,
)
end
end
end
@@ -0,0 +1,56 @@
# frozen_string_literal: true
RSpec.describe ComponentIndexSerializer do
fab!(:theme_1) { Fabricate(:theme) }
fab!(:theme_2) { Fabricate(:theme) }
fab!(:component) do
Fabricate(
:theme,
component: true,
parent_themes: [theme_1, theme_2],
remote_theme:
RemoteTheme.create!(
remote_url: "https://github.com/discourse/discourse.git",
commits_behind: 3,
authors: "CDCK Inc.",
),
theme_fields: [
ThemeField.new(
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: <<~YAML,
en:
theme_metadata:
description: "Description of my component"
YAML
),
],
)
end
let(:json) { described_class.new(component, root: false).as_json }
it "includes remote_theme object" do
expect(json[:remote_theme][:id]).to eq(component.remote_theme.id)
expect(json[:remote_theme][:commits_behind]).to eq(3)
expect(json[:remote_theme][:authors]).to eq("CDCK Inc.")
end
it "includes parent themes objects" do
expect(json[:parent_themes].map { |o| o[:name] }).to contain_exactly(theme_1.name, theme_2.name)
end
it "includes the component name" do
expect(json[:name]).to eq(component.name)
end
it "includes the component id" do
expect(json[:id]).to eq(component.id)
end
it "includes the component description" do
expect(json[:description]).to eq("Description of my component")
end
end
@@ -0,0 +1,338 @@
# frozen_string_literal: true
describe "Admin Customize Themes Config Area Page", type: :system do
fab!(:admin)
fab!(:parent_theme) { Fabricate(:theme, name: "A theme") }
fab!(:parent_theme_2) { Fabricate(:theme, name: "B theme") }
fab!(:parent_theme_3) { Fabricate(:theme, name: "C theme") }
fab!(:parent_theme_4) { Fabricate(:theme, name: "D theme") }
let(:config_area) { PageObjects::Pages::AdminCustomizeComponentsConfigArea.new }
let(:toasts) { PageObjects::Components::Toasts.new }
let(:dialog) { PageObjects::Components::Dialog.new }
before { sign_in(admin) }
context "when there are components installed" do
fab!(:enabled_component) do
Fabricate(
:theme,
name: "Glorious component",
component: true,
enabled: true,
parent_themes: [parent_theme, parent_theme_2, parent_theme_3, parent_theme_4],
)
end
fab!(:disabled_component) do
Fabricate(:theme, name: "Glossy component", component: true, enabled: false)
end
fab!(:remote_component) do
Fabricate(
:theme,
component: true,
enabled: false,
parent_themes: [parent_theme_3],
remote_theme:
RemoteTheme.create!(
remote_url: "https://github.com/discourse/tc-1.git",
authors: "CDCK Inc.",
),
theme_fields: [
ThemeField.new(
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: <<~YAML,
en:
theme_metadata:
description: "Description of my remote component"
YAML
),
],
)
end
fab!(:remote_component_with_update) do
Fabricate(
:theme,
component: true,
enabled: false,
remote_theme:
RemoteTheme.create!(remote_url: "https://github.com/discourse/tc-2", commits_behind: 4),
)
end
it "can enable/disable components" do
config_area.visit
expect(config_area.component(enabled_component.id).enabled_toggle).to be_checked
expect(config_area.component(disabled_component.id).enabled_toggle).to be_unchecked
config_area.component(enabled_component.id).enabled_toggle.toggle
config_area.component(disabled_component.id).enabled_toggle.toggle
expect(config_area.component(enabled_component.id).enabled_toggle).to be_unchecked
expect(config_area.component(disabled_component.id).enabled_toggle).to be_checked
expect(enabled_component.reload.enabled).to eq(false)
expect(disabled_component.reload.enabled).to eq(true)
end
it "can filter components by status" do
config_area.visit
config_area.status_selector.select("active")
expect(config_area).to be_loading
expect(config_area.components_shown).to contain_exactly(
enabled_component.id,
remote_component.id,
)
config_area.status_selector.select("inactive")
expect(config_area).to be_loading
expect(config_area.components_shown).to contain_exactly(
disabled_component.id,
remote_component_with_update.id,
)
config_area.status_selector.select("updates_available")
expect(config_area).to be_loading
expect(config_area.components_shown).to contain_exactly(remote_component_with_update.id)
end
it "can filter components by name" do
config_area.visit
config_area.name_filter_input.fill_in(with: "glo")
expect(config_area).to be_loading
expect(config_area.components_shown).to contain_exactly(
enabled_component.id,
disabled_component.id,
)
end
it "keeps the filters shown when there are no components matching the filters" do
config_area.visit
config_area.name_filter_input.fill_in(with: "stringthatshouldnotmatchanything")
expect(config_area).to have_no_components_found_text
expect(config_area).to have_no_components
expect(config_area).to have_name_filter_input
expect(config_area).to have_status_selector
end
it "navigates to the component page when clicking the Edit button" do
config_area.visit
config_area.component(enabled_component.id).edit_button.click
expect(page).to have_current_path("/admin/customize/themes/#{enabled_component.id}")
end
it "displays various metadata for components" do
disabled_component.update!(parent_themes: [parent_theme])
remote_component.update!(parent_themes: [parent_theme, parent_theme_2])
remote_component_with_update.update!(
parent_themes: [parent_theme, parent_theme_2, parent_theme_3],
)
config_area.visit
expect(config_area.component(remote_component.id)).to have_author("CDCK Inc.")
expect(config_area.component(remote_component.id)).to have_description(
"Description of my remote component",
)
expect(config_area.component(remote_component.id)).to be_not_pending_update
expect(config_area.component(remote_component_with_update.id)).to be_pending_update
expect(config_area.component(disabled_component.id)).to have_one_parent_theme("A theme")
expect(config_area.component(remote_component.id)).to have_two_parent_themes(
"A theme",
"B theme",
)
expect(config_area.component(remote_component_with_update.id)).to have_three_parent_themes(
"A theme",
"B theme",
"C theme",
)
expect(config_area.component(enabled_component.id)).to have_three_and_more_parent_themes(
"A theme",
"B theme",
"C theme",
1,
)
end
it "shows actions that make sense for each component" do
config_area.visit
config_area.component(enabled_component.id).more_actions_menu.expand
expect(config_area.component(enabled_component.id)).to have_no_check_for_updates_button
expect(config_area.component(enabled_component.id)).to have_no_update_button
expect(config_area.component(enabled_component.id).preview_button["href"]).to end_with(
"/admin/themes/#{enabled_component.id}/preview",
)
expect(config_area.component(enabled_component.id).export_button["href"]).to end_with(
"/admin/customize/themes/#{enabled_component.id}/export",
)
config_area.component(enabled_component.id).more_actions_menu.collapse
config_area.component(disabled_component.id).more_actions_menu.expand
expect(config_area.component(disabled_component.id)).to have_no_check_for_updates_button
expect(config_area.component(disabled_component.id)).to have_no_update_button
expect(config_area.component(disabled_component.id).preview_button["href"]).to end_with(
"/admin/themes/#{disabled_component.id}/preview",
)
expect(config_area.component(disabled_component.id).export_button["href"]).to end_with(
"/admin/customize/themes/#{disabled_component.id}/export",
)
config_area.component(disabled_component.id).more_actions_menu.collapse
config_area.component(remote_component.id).more_actions_menu.expand
expect(config_area.component(remote_component.id)).to have_check_for_updates_button
expect(config_area.component(remote_component.id)).to have_no_update_button
expect(config_area.component(remote_component.id).preview_button["href"]).to end_with(
"/admin/themes/#{remote_component.id}/preview",
)
expect(config_area.component(remote_component.id).export_button["href"]).to end_with(
"/admin/customize/themes/#{remote_component.id}/export",
)
config_area.component(remote_component.id).more_actions_menu.collapse
config_area.component(remote_component_with_update.id).more_actions_menu.expand
expect(
config_area.component(remote_component_with_update.id),
).to have_no_check_for_updates_button
expect(config_area.component(remote_component_with_update.id)).to have_update_button
expect(
config_area.component(remote_component_with_update.id).preview_button["href"],
).to end_with("/admin/themes/#{remote_component_with_update.id}/preview")
expect(
config_area.component(remote_component_with_update.id).export_button["href"],
).to end_with("/admin/customize/themes/#{remote_component_with_update.id}/export")
config_area.component(remote_component_with_update.id).more_actions_menu.collapse
end
it "can delete a component" do
config_area.visit
config_area.component(disabled_component.id).more_actions_menu.expand
config_area.component(disabled_component.id).delete_button.click
dialog.click_yes
expect(toasts).to have_success(
I18n.t(
"admin_js.admin.config_areas.themes_and_components.components.deleted_successfully",
name: disabled_component.name,
),
)
expect(Theme.find_by(id: disabled_component.id)).to eq(nil)
expect(config_area).to have_no_component(disabled_component.id)
end
describe "checking for updates" do
let(:repo) do
setup_git_repo("about.json" => { name: "discourse-component-tt1", component: true }.to_json)
end
let(:url) do
MockGitImporter.register("https://example.com/discourse-component-tt1.git", repo)
end
before do
remote_component.remote_theme.update!(remote_url: url)
remote_component.remote_theme.update_from_remote
end
after { `rm -fr #{repo}` }
around(:each) { |group| MockGitImporter.with_mock { group.run } }
it 'shows an "Update to latest" button if there is a new update' do
config_area.visit
config_area.component(remote_component.id).more_actions_menu.expand
add_to_git_repo(repo, "about.json" => { name: "updated-name", component: true }.to_json)
config_area.component(remote_component.id).check_for_updates_button.click
expect(toasts).to have_default(
I18n.t(
"admin_js.admin.config_areas.themes_and_components.components.new_update_for_component",
name: remote_component.name,
),
)
expect(config_area.component(remote_component.id)).to have_update_button
expect(config_area.component(remote_component.id)).to be_pending_update
end
it 'keeps the "Check for updates" button if there is no new update' do
config_area.visit
config_area.component(remote_component.id).more_actions_menu.expand
config_area.component(remote_component.id).check_for_updates_button.click
expect(toasts).to have_default(
I18n.t(
"admin_js.admin.config_areas.themes_and_components.components.component_up_to_date",
name: remote_component.name,
),
)
expect(config_area.component(remote_component.id)).to have_check_for_updates_button
expect(config_area.component(remote_component.id)).to be_not_pending_update
end
end
describe "performing an update" do
let(:repo) do
setup_git_repo("about.json" => { name: "discourse-component-tt2", component: true }.to_json)
end
let(:url) do
MockGitImporter.register("https://example.com/discourse-component-tt2.git", repo)
end
before do
remote_component_with_update.remote_theme.update!(remote_url: url)
remote_component_with_update.remote_theme.update_from_remote
add_to_git_repo(repo, "about.json" => { name: "updated-name-tt2", component: true }.to_json)
remote_component_with_update.remote_theme.update_remote_version
end
after { `rm -fr #{repo}` }
around(:each) { |group| MockGitImporter.with_mock { group.run } }
it 'shows the "Check for updates" button after updating' do
config_area.visit
config_area.component(remote_component_with_update.id).more_actions_menu.expand
config_area.component(remote_component_with_update.id).update_button.click
expect(toasts).to have_success(
I18n.t(
"admin_js.admin.config_areas.themes_and_components.components.updated_successfully",
name: remote_component_with_update.name,
),
)
expect(
config_area.component(remote_component_with_update.id),
).to have_check_for_updates_button
expect(config_area.component(remote_component_with_update.id)).to be_not_pending_update
end
end
end
context "when there are no components installed" do
it "doesn't display filters when there are no components installed" do
config_area.visit
expect(config_area).to have_no_components_installed_text
expect(config_area).to have_no_components
expect(config_area).to have_no_status_selector
expect(config_area).to have_no_name_filter_input
end
end
end
@@ -1,38 +0,0 @@
# frozen_string_literal: true
describe "Admin Customize Config Area Page", type: :system do
fab!(:admin)
let(:config_area) { PageObjects::Pages::AdminCustomizeConfigArea.new }
let(:install_modal) { PageObjects::Modals::InstallTheme.new }
before { sign_in(admin) }
context "when in the themes tab" do
it "has a special card for installing new themes" do
config_area.visit
expect(config_area.install_card).to have_text(
I18n.t("admin_js.admin.config_areas.themes_and_components.themes.new_theme"),
)
config_area.install_card.find(".btn-primary").click
expect(install_modal).to be_open
expect(install_modal.popular_options.first).to have_text("Air")
end
end
context "when in the components tab" do
it "has a special card for installing new components" do
config_area.visit_components
expect(config_area.install_card).to have_text(
I18n.t("admin_js.admin.config_areas.themes_and_components.components.new_component"),
)
config_area.install_card.find(".btn-primary").click
expect(install_modal).to be_open
expect(install_modal.popular_options.first).to have_text("Brand Header")
end
end
end
@@ -0,0 +1,22 @@
# frozen_string_literal: true
describe "Admin Customize Themes Config Area Page", type: :system do
fab!(:admin)
let(:config_area) { PageObjects::Pages::AdminCustomizeThemesConfigArea.new }
let(:install_modal) { PageObjects::Modals::InstallTheme.new }
before { sign_in(admin) }
it "has a special card for installing new themes" do
config_area.visit
expect(config_area.install_card).to have_text(
I18n.t("admin_js.admin.config_areas.themes_and_components.themes.new_theme"),
)
config_area.install_card.find(".btn-primary").click
expect(install_modal).to be_open
expect(install_modal.popular_options.first).to have_text("Air")
end
end
@@ -34,6 +34,14 @@ module PageObjects
def option(selector)
within("#d-menu-portals") { find(selector) }
end
def has_option?(selector)
within("#d-menu-portals") { has_css?(selector) }
end
def has_no_option?(selector)
within("#d-menu-portals") { has_no_css?(selector) }
end
end
end
end
@@ -0,0 +1,29 @@
# frozen_string_literal: true
module PageObjects
module Components
class DSelect < PageObjects::Components::Base
attr_reader :select_element
def initialize(input)
if input.is_a?(Capybara::Node::Element)
@select_element = input
else
@select_element = find(input)
end
end
def value
@select_element.value
end
def select(value)
@select_element.find("option[value='#{value}']").select_option
@select_element.execute_script(<<~JS, @select_element)
var selector = arguments[0];
selector.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
JS
end
end
end
end
@@ -41,7 +41,7 @@ module PageObjects
when "menu"
component.find(".fk-d-menu__trigger")["data-value"]
when "select"
component.find("select").value
PageObjects::Components::DSelect.new(component.find("select")).value
when "composer"
component.find("textarea").value
when "image"
@@ -122,12 +122,9 @@ module PageObjects
picker.search(value)
picker.select_row_by_value(value)
when "select"
selector = component.find(".form-kit__control-select")
selector.find(".form-kit__control-option[value='#{value}']").select_option
selector.execute_script(<<~JS, selector)
var selector = arguments[0];
selector.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
JS
PageObjects::Components::DSelect.new(component.find(".form-kit__control-select")).select(
value,
)
when "menu"
trigger = component.find(".fk-d-menu__trigger.form-kit__control-menu-trigger")
trigger.click
@@ -0,0 +1,181 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminCustomizeComponentsConfigArea < PageObjects::Pages::Base
class ComponentRow < PageObjects::Components::Base
def initialize(selector)
@selector = selector
@element = find(selector)
end
def enabled_toggle
PageObjects::Components::DToggleSwitch.new(
"#{@selector} .admin-config-components__toggle",
)
end
def edit_button
@element.find(".admin-config-components__edit")
end
def has_author?(name)
@element.find(".admin-config-components__author-name").has_text?(
I18n.t(
"admin_js.admin.config_areas.themes_and_components.components.by_author",
name: name,
),
)
end
def has_description?(description)
@element.find(".admin-config-components__description").has_text?(description)
end
def has_one_parent_theme?(name)
@element.find(".admin-config-components__parent-themes").text == name
end
def has_two_parent_themes?(name1, name2)
@element.find(".admin-config-components__parent-themes").text ==
I18n.t(
"admin_js.admin.config_areas.themes_and_components.components.parent_themes_two",
name1:,
name2:,
)
end
def has_three_parent_themes?(name1, name2, name3)
@element.find(".admin-config-components__parent-themes").text ==
I18n.t(
"admin_js.admin.config_areas.themes_and_components.components.parent_themes_three",
name1:,
name2:,
name3:,
)
end
def has_three_and_more_parent_themes?(name1, name2, name3, count)
@element.find(".admin-config-components__parent-themes").text ==
I18n.t(
"admin_js.admin.config_areas.themes_and_components.components.parent_themes_more_than_three",
name1:,
name2:,
name3:,
count:,
)
end
def pending_update?
@element.has_css?(".admin-config-components__update-available")
end
def not_pending_update?
@element.has_no_css?(".admin-config-components__update-available")
end
def more_actions_menu
PageObjects::Components::DMenu.new(@element.find(".component-menu-trigger"))
end
def preview_button
more_actions_menu.option(".admin-config-components__preview")
end
def has_check_for_updates_button?
more_actions_menu.has_option?(".admin-config-components__check-updates")
end
def has_no_check_for_updates_button?
more_actions_menu.has_no_option?(".admin-config-components__check-updates")
end
def check_for_updates_button
more_actions_menu.option(".admin-config-components__check-updates")
end
def has_update_button?
more_actions_menu.has_option?(".admin-config-components__update")
end
def has_no_update_button?
more_actions_menu.has_no_option?(".admin-config-components__update")
end
def update_button
more_actions_menu.option(".admin-config-components__update")
end
def export_button
more_actions_menu.option(".admin-config-components__export")
end
def delete_button
more_actions_menu.option(".admin-config-components__delete")
end
end
def visit
page.visit("/admin/config/customize/components")
end
def loading?
has_css?(".loading-container.visible")
end
def component(id)
ComponentRow.new(".admin-config-components__component-row[data-component-id=\"#{id}\"]")
end
def has_no_component?(id)
has_no_css?(".admin-config-components__component-row[data-component-id=\"#{id}\"]")
end
def status_selector
PageObjects::Components::DSelect.new(find(".admin-config-components__status-filter select"))
end
def name_filter_input
find(".admin-config-components__name-filter input")
end
def has_no_components?
has_no_css?(".admin-config-components__component-row")
end
def components_shown
all(".admin-config-components__component-row").map { |node| node["data-component-id"].to_i }
end
def has_name_filter_input?
has_css?(".admin-config-components__name-filter")
end
def has_status_selector?
has_css?(".admin-config-components__status-filter")
end
def has_no_name_filter_input?
has_no_css?(".admin-config-components__name-filter")
end
def has_no_status_selector?
has_no_css?(".admin-config-components__status-filter")
end
def has_no_components_installed_text?
page.has_text?(
I18n.t("admin_js.admin.config_areas.themes_and_components.components.no_components"),
)
end
def has_no_components_found_text?
page.has_text?(
I18n.t(
"admin_js.admin.config_areas.themes_and_components.components.no_components_found",
),
)
end
end
end
end
@@ -2,15 +2,11 @@
module PageObjects
module Pages
class AdminCustomizeConfigArea < PageObjects::Pages::Base
class AdminCustomizeThemesConfigArea < PageObjects::Pages::Base
def visit
page.visit("/admin/config/customize")
end
def visit_components
page.visit("/admin/config/customize/components")
end
def install_card
find(".theme-install-card")
end