FEATURE: delete multiple inactive themes/components (#23788)

Ability to select multiple inactive themes or components and delete them all together
This commit is contained in:
Krzysztof Kotlarek
2023-10-09 08:35:53 +11:00
committed by GitHub
parent 60e624e768
commit e94b553e9a
13 changed files with 330 additions and 9 deletions

View File

@ -8,6 +8,13 @@
</span> </span>
<div class="info"> <div class="info">
{{#if @selectInactiveMode}}
<Input
@checked={{this.theme.markedToDelete}}
id={{this.theme.id}}
@type="checkbox"
/>
{{/if}}
<span class="name"> <span class="name">
{{this.theme.name}} {{this.theme.name}}
</span> </span>

View File

@ -41,11 +41,61 @@
{{#if this.hasInactiveThemes}} {{#if this.hasInactiveThemes}}
<div class="themes-list-item inactive-indicator"> <div class="themes-list-item inactive-indicator">
<span class="empty"> <span class="empty">
{{#if this.themesTabActive}} <div class="info">
{{i18n "admin.customize.theme.inactive_themes"}} {{#if this.selectInactiveMode}}
<Input
@type="checkbox"
@checked={{(or
(eq this.allInactiveSelected true)
(eq this.someInactiveSelected true)
)}}
class="toggle-all-inactive"
indeterminate={{this.someInactiveSelected}}
{{on "click" this.toggleAllInactive}}
/>
{{else}} {{else}}
{{i18n "admin.customize.theme.inactive_components"}} <DButton
class="btn-flat select-inactive-mode"
@action={{this.toggleInactiveMode}}
>
{{d-icon "list"}}
</DButton>
{{/if}} {{/if}}
{{#if this.selectInactiveMode}}
<span class="select-inactive-mode-label">
{{i18n
"admin.customize.theme.selected"
count=this.selectedCount
}}
</span>
{{else if this.themesTabActive}}
<span class="header">
{{i18n "admin.customize.theme.inactive_themes"}}
</span>
{{else}}
<span class="header">
{{i18n "admin.customize.theme.inactive_components"}}
</span>
{{/if}}
{{#if this.selectInactiveMode}}
<a
href
{{on "click" this.toggleInactiveMode}}
class="cancel-select-inactive-mode"
>
{{i18n "admin.customize.theme.cancel"}}
</a>
<DButton
class="btn btn-delete"
@action={{this.deleteConfirmation}}
@disabled={{(eq this.selectedCount 0)}}
>
{{d-icon "trash-alt"}}
Delete
</DButton>
{{/if}}
</div>
</span> </span>
</div> </div>
{{/if}} {{/if}}
@ -57,6 +107,7 @@
@classNames="inactive-theme" @classNames="inactive-theme"
@theme={{theme}} @theme={{theme}}
@navigateToTheme={{action "navigateToTheme" theme}} @navigateToTheme={{action "navigateToTheme" theme}}
@selectInactiveMode={{this.selectInactiveMode}}
/> />
{{/each}} {{/each}}
{{/if}} {{/if}}

View File

@ -3,16 +3,19 @@ import { inject as service } from "@ember/service";
import { equal, gt, gte } from "@ember/object/computed"; import { equal, gt, gte } from "@ember/object/computed";
import { COMPONENTS, THEMES } from "admin/models/theme"; import { COMPONENTS, THEMES } from "admin/models/theme";
import Component from "@ember/component"; import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed, { bind } from "discourse-common/utils/decorators";
import { action } from "@ember/object"; import { action } from "@ember/object";
import DeleteThemesConfirm from "discourse/components/modal/delete-themes-confirm";
@classNames("themes-list") @classNames("themes-list")
export default class ThemesList extends Component { export default class ThemesList extends Component {
@service router; @service router;
@service modal;
THEMES = THEMES; THEMES = THEMES;
COMPONENTS = COMPONENTS; COMPONENTS = COMPONENTS;
filterTerm = null; filterTerm = null;
selectInactiveMode = false;
@gt("themesList.length", 0) hasThemes; @gt("themesList.length", 0) hasThemes;
@ -40,6 +43,7 @@ export default class ThemesList extends Component {
"currentTab", "currentTab",
"themesList.@each.user_selectable", "themesList.@each.user_selectable",
"themesList.@each.default", "themesList.@each.default",
"themesList.@each.markedToDelete",
"filterTerm" "filterTerm"
) )
inactiveThemes(themes) { inactiveThemes(themes) {
@ -56,6 +60,16 @@ export default class ThemesList extends Component {
return this._filterThemes(results, this.filterTerm); return this._filterThemes(results, this.filterTerm);
} }
@discourseComputed("themesList.@each.markedToDelete")
selectedThemesOrComponents() {
return this.themesList.filter((theme) => theme.markedToDelete);
}
@discourseComputed("themesList.@each.markedToDelete")
selectedCount() {
return this.selectedThemesOrComponents.length;
}
@discourseComputed( @discourseComputed(
"themesList", "themesList",
"currentTab", "currentTab",
@ -84,6 +98,18 @@ export default class ThemesList extends Component {
} }
return this._filterThemes(results, this.filterTerm); return this._filterThemes(results, this.filterTerm);
} }
@discourseComputed("themesList.@each.markedToDelete")
someInactiveSelected() {
return (
this.selectedCount > 0 &&
this.selectedCount !== this.inactiveThemes.length
);
}
@discourseComputed("themesList.@each.markedToDelete")
allInactiveSelected() {
return this.selectedCount === this.inactiveThemes.length;
}
_filterThemes(themes, term) { _filterThemes(themes, term) {
term = term?.trim()?.toLowerCase(); term = term?.trim()?.toLowerCase();
@ -93,9 +119,17 @@ export default class ThemesList extends Component {
return themes.filter(({ name }) => name.toLowerCase().includes(term)); return themes.filter(({ name }) => name.toLowerCase().includes(term));
} }
@bind
toggleInactiveMode(event) {
event?.preventDefault();
this.inactiveThemes.forEach((theme) => theme.set("markedToDelete", false));
this.toggleProperty("selectInactiveMode");
}
@action @action
changeView(newTab) { changeView(newTab) {
if (newTab !== this.currentTab) { if (newTab !== this.currentTab) {
this.set("selectInactiveMode", false);
this.set("currentTab", newTab); this.set("currentTab", newTab);
if (!this.showFilter) { if (!this.showFilter) {
this.set("filterTerm", null); this.set("filterTerm", null);
@ -107,4 +141,41 @@ export default class ThemesList extends Component {
navigateToTheme(theme) { navigateToTheme(theme) {
this.router.transitionTo("adminCustomizeThemes.show", theme); this.router.transitionTo("adminCustomizeThemes.show", theme);
} }
@action
toggleAllInactive() {
const markedToDelete = this.selectedCount === 0;
this.inactiveThemes.forEach((theme) =>
theme.set("markedToDelete", markedToDelete)
);
}
@action
deleteConfirmation() {
this.modal.show(DeleteThemesConfirm, {
model: {
selectedThemesOrComponents: this.selectedThemesOrComponents,
type: this.themesTabActive ? "themes" : "components",
refreshAfterDelete: () => {
this.set("selectInactiveMode", false);
if (this.themesTabActive) {
this.set(
"themes",
this.themes.filter(
(theme) => !this.selectedThemesOrComponents.includes(theme)
)
);
} else {
this.set(
"components",
this.components.filter(
(component) =>
!this.selectedThemesOrComponents.includes(component)
)
);
}
},
},
});
}
} }

View File

@ -0,0 +1,18 @@
<DModal
@closeModal={{@closeModal}}
@title={{i18n "admin.customize.bulk_delete"}}
>
<:body>
{{i18n (concat "admin.customize.bulk_" @model.type "_delete_confirm")}}
<ul>
{{#each @model.selectedThemesOrComponents as |theme|}}
<li>{{theme.name}}</li>
{{/each}}
</ul>
</:body>
<:footer>
<DButton class="btn-primary" @action={{this.delete}} @label="yes_value" />
<DModalCancel @close={{@closeModal}} />
</:footer>
</DModal>

View File

@ -0,0 +1,21 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default class DeleteThemesConfirmComponent extends Component {
@action
delete() {
ajax(`/admin/themes/bulk_destroy.json`, {
type: "DELETE",
data: {
theme_ids: this.args.model.selectedThemesOrComponents.mapBy("id"),
},
})
.then(() => {
this.args.model.refreshAfterDelete();
this.args.closeModal();
})
.catch(popupAjaxError);
}
}

View File

@ -266,10 +266,14 @@
border-bottom: 1px solid var(--primary-low); border-bottom: 1px solid var(--primary-low);
display: flex; display: flex;
.select-inactive-mode-label {
color: var(--tertiary);
font-weight: bold;
}
&.inactive-theme { &.inactive-theme {
color: var(--primary-high); color: var(--primary-high);
background: var(--primary-very-low); background: var(--primary-very-low);
font-size: var(--font-down-1);
&:not(.selected):hover { &:not(.selected):hover {
color: var(--primary); color: var(--primary);
} }
@ -285,6 +289,11 @@
padding-left: 0.33em; padding-left: 0.33em;
padding-top: 1em; padding-top: 1em;
} }
.btn.select-inactive-mode {
padding-left: 0;
padding-top: 0;
padding-bottom: 0;
}
} }
&:not(.inactive-indicator):not(.selected):hover { &:not(.inactive-indicator):not(.selected):hover {
background-color: var(--primary-very-low); background-color: var(--primary-very-low);
@ -319,11 +328,25 @@
.info { .info {
overflow: hidden; overflow: hidden;
display: flex; display: flex;
font-size: var(--font-up-1); align-items: center;
height: 2em;
.icons { .icons {
margin-left: auto; margin-left: auto;
} }
.cancel-select-inactive-mode {
margin-left: auto;
}
.btn-delete {
font-size: var(--font-down-1);
margin-left: 0.5em;
svg {
margin-right: 0.5em;
}
}
input {
margin-top: 0;
}
} }
.components-list { .components-list {

View File

@ -265,6 +265,18 @@ class Admin::ThemesController < Admin::AdminController
respond_to { |format| format.json { head :no_content } } respond_to { |format| format.json { head :no_content } }
end end
def bulk_destroy
themes = Theme.where(id: params[:theme_ids])
raise Discourse::InvalidParameters.new(:id) unless themes.present?
ActiveRecord::Base.transaction do
themes.each { |theme| StaffActionLogger.new(current_user).log_theme_destroy(theme) }
themes.destroy_all
end
respond_to { |format| format.json { head :no_content } }
end
def show def show
@theme = Theme.include_relations.find_by(id: params[:id]) @theme = Theme.include_relations.find_by(id: params[:id])
raise Discourse::InvalidParameters.new(:id) unless @theme raise Discourse::InvalidParameters.new(:id) unless @theme

View File

@ -5112,6 +5112,9 @@ en:
install: "Install" install: "Install"
delete: "Delete" delete: "Delete"
delete_confirm: 'Are you sure you want to delete "%{theme_name}"?' delete_confirm: 'Are you sure you want to delete "%{theme_name}"?'
bulk_delete: 'Are you sure?'
bulk_themes_delete_confirm: "This will uninstall the following themes, they will no longer be useable by any users on your site:"
bulk_components_delete_confirm: "This will uninstall the following components, they will no longer be useable by any users on your site:"
color: "Color" color: "Color"
opacity: "Opacity" opacity: "Opacity"
copy: "Duplicate" copy: "Duplicate"
@ -5182,6 +5185,8 @@ en:
convert_theme_tooltip: "Convert this theme to component" convert_theme_tooltip: "Convert this theme to component"
inactive_themes: "Inactive themes:" inactive_themes: "Inactive themes:"
inactive_components: "Unused components:" inactive_components: "Unused components:"
selected: "%{count} selected"
cancel: "Cancel"
broken_theme_tooltip: "This theme has errors in its CSS, HTML or YAML" broken_theme_tooltip: "This theme has errors in its CSS, HTML or YAML"
disabled_component_tooltip: "This component has been disabled" disabled_component_tooltip: "This component has been disabled"
default_theme_tooltip: "This theme is the site's default theme" default_theme_tooltip: "This theme is the site's default theme"

View File

@ -230,6 +230,7 @@ Discourse::Application.routes.draw do
post "import" => "themes#import" post "import" => "themes#import"
post "upload_asset" => "themes#upload_asset" post "upload_asset" => "themes#upload_asset"
post "generate_key_pair" => "themes#generate_key_pair" post "generate_key_pair" => "themes#generate_key_pair"
delete "bulk_destroy" => "themes#bulk_destroy"
end end
end end

View File

@ -1052,4 +1052,23 @@ RSpec.describe Admin::ThemesController do
include_examples "theme update not allowed" include_examples "theme update not allowed"
end end
end end
describe "#bulk_destroy" do
fab!(:theme) { Fabricate(:theme, name: "Awesome Theme") }
fab!(:theme_2) { Fabricate(:theme, name: "Another awesome Theme") }
let(:theme_ids) { [theme.id, theme_2.id] }
before { sign_in(admin) }
it "destroys all selected the themes" do
expect do
delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids }
end.to change { Theme.count }.by(-2)
end
it "logs the theme destroy action for each theme" do
StaffActionLogger.any_instance.expects(:log_theme_destroy).twice
delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids }
end
end
end end

View File

@ -7,6 +7,41 @@ describe "Admin Customize Themes", type: :system do
before { sign_in(admin) } before { sign_in(admin) }
describe "when visiting the page to customize themes" do
fab!(:theme_2) { Fabricate(:theme) }
fab!(:theme_3) { Fabricate(:theme) }
let(:admin_customize_themes_page) { PageObjects::Pages::AdminCustomizeThemes.new }
let(:delete_themes_confirm_modal) { PageObjects::Modals::DeleteThemesConfirm.new }
it "should allow admin to bulk delete inactive themes" do
visit("/admin/customize/themes")
expect(admin_customize_themes_page).to have_inactive_themes
admin_customize_themes_page.click_select_inactive_mode
expect(admin_customize_themes_page).to have_inactive_themes_selected(count: 0)
admin_customize_themes_page.toggle_all_inactive
expect(admin_customize_themes_page).to have_inactive_themes_selected(count: 3)
admin_customize_themes_page.cancel_select_inactive_mode
expect(admin_customize_themes_page).to have_select_inactive_mode_button
admin_customize_themes_page.click_select_inactive_mode
expect(admin_customize_themes_page).to have_disabled_delete_theme_button
admin_customize_themes_page.toggle_all_inactive
admin_customize_themes_page.click_delete_themes_button
expect(delete_themes_confirm_modal).to have_theme("Cool theme 1")
expect(delete_themes_confirm_modal).to have_theme("Cool theme 2")
expect(delete_themes_confirm_modal).to have_theme("Cool theme 3")
delete_themes_confirm_modal.confirm
expect(admin_customize_themes_page).to have_no_inactive_themes
end
end
describe "when visiting the page to customize the theme" do describe "when visiting the page to customize the theme" do
it "should allow admin to update the color scheme of the theme" do it "should allow admin to update the color scheme of the theme" do
visit("/admin/customize/themes/#{theme.id}") visit("/admin/customize/themes/#{theme.id}")

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module PageObjects
module Modals
class DeleteThemesConfirm < PageObjects::Pages::Base
def has_theme?(name)
has_css?(".modal li", text: name)
end
def confirm
find(".modal-footer .btn-primary").click
end
end
end
end

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminCustomizeThemes < PageObjects::Pages::Base
def has_inactive_themes?
has_css?(".inactive-indicator")
end
def has_no_inactive_themes?
has_no_css?(".inactive-indicator")
end
def has_select_inactive_mode_button?
has_css?(".select-inactive-mode")
end
def click_select_inactive_mode
find(".select-inactive-mode").click
end
def cancel_select_inactive_mode
find(".cancel-select-inactive-mode").click
end
def has_inactive_themes_selected?(count:)
has_css?(".inactive-theme input:checked", count: count)
end
def toggle_all_inactive
find(".toggle-all-inactive").click
end
def has_disabled_delete_theme_button?
find_button("Delete", disabled: true)
end
def click_delete_themes_button
find(".btn-delete").click
end
end
end
end