DEV: configurable public sidebar sections (#20303)

Extension of https://github.com/discourse/discourse/pull/20057

Admin can create a public session visible to everyone. An additional checkbox is displayed for staff members.
This commit is contained in:
Krzysztof Kotlarek 2023-02-22 08:55:44 +11:00 committed by GitHub
parent e3ee81aa3c
commit b9d037770c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 350 additions and 17 deletions

View File

@ -2,14 +2,9 @@
{{#each this.sections as |section|}}
<Sidebar::Section
@sectionName={{section.slug}}
@headerLinkText={{section.title}}
@headerLinkText={{section.decoratedTitle}}
@collapsable={{true}}
@headerActions={{array
(hash
action=(action this.editSection section)
title=(i18n "sidebar.sections.custom.edit")
)
}}
@headerActions={{section.headerActions}}
@headerActionsIcon="pencil-alt"
>
{{#each section.links as |link|}}

View File

@ -1,15 +1,43 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service";
import RouteInfoHelper from "discourse/lib/sidebar/route-info-helper";
import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
import { iconHTML } from "discourse-common/lib/icon-library";
import { htmlSafe } from "@ember/template";
import { bind } from "discourse-common/utils/decorators";
export default class SidebarUserCustomSections extends Component {
@service currentUser;
@service router;
@service messageBus;
constructor() {
super(...arguments);
this.messageBus.subscribe("/refresh-sidebar-sections", this._refresh);
}
willDestroy() {
this.messageBus.unsubscribe("/refresh-sidebar-sections");
}
get sections() {
this.currentUser.sidebarSections.forEach((section) => {
if (!section.public || this.currentUser.staff) {
section.headerActions = [
{
action: () => {
return showModal("sidebar-section-form", { model: section });
},
title: I18n.t("sidebar.sections.custom.edit"),
},
];
}
section.decoratedTitle =
section.public && this.currentUser.staff
? htmlSafe(`${iconHTML("globe")} ${section.title}`)
: section.title;
section.links.forEach((link) => {
const routeInfoHelper = new RouteInfoHelper(this.router, link.value);
link.route = routeInfoHelper.route;
@ -20,8 +48,10 @@ export default class SidebarUserCustomSections extends Component {
return this.currentUser.sidebarSections;
}
@action
editSection(section) {
showModal("sidebar-section-form", { model: section });
@bind
_refresh() {
return ajax("/sidebar_sections.json", {}).then((json) => {
this.currentUser.set("sidebar_sections", json.sidebar_sections);
});
}
}

View File

@ -13,8 +13,9 @@ class Section {
@tracked title;
@tracked links;
constructor({ title, links, id }) {
constructor({ title, links, id, publicSection }) {
this.title = title;
this.public = publicSection;
this.links = links;
this.id = id;
}
@ -112,6 +113,7 @@ export default Controller.extend(ModalFunctionality, {
if (this.model) {
return new Section({
title: this.model.title,
publicSection: this.model.public,
links: A(
this.model.links.map(
(link) =>
@ -140,6 +142,7 @@ export default Controller.extend(ModalFunctionality, {
dataType: "json",
data: JSON.stringify({
title: this.model.title,
public: this.model.public,
links: this.model.links.map((link) => {
return {
icon: link.icon,
@ -168,6 +171,7 @@ export default Controller.extend(ModalFunctionality, {
dataType: "json",
data: JSON.stringify({
title: this.model.title,
public: this.model.public,
links: this.model.links.map((link) => {
return {
id: link.id,

View File

@ -69,6 +69,18 @@
@icon="plus"
@label="sidebar.sections.custom.links.add"
/>
{{#if this.currentUser.staff}}
<div class="row-wrapper">
<label class="checkbox-label">
<Input
@type="checkbox"
@checked={{this.model.public}}
class="mark-public"
/>
{{i18n "sidebar.sections.custom.public"}}
</label>
</div>
{{/if}}
</form>
</DModalBody>

View File

@ -130,6 +130,14 @@
.sidebar-section-wrapper {
padding-bottom: 0;
}
.d-icon-globe {
position: absolute;
left: 0.5em;
height: 0.75em;
width: 0.75em;
margin-top: 0.15em;
align-items: center;
}
}
.sidebar-section-form-modal {
.modal-inner-container {
@ -138,7 +146,7 @@
form {
margin-bottom: 0;
}
input {
.input-group input {
width: 100%;
}
input.warning {

View File

@ -3,6 +3,16 @@
class SidebarSectionsController < ApplicationController
requires_login
before_action :check_if_member_of_group
before_action :check_access_if_public
def index
sections =
SidebarSection
.where("public OR user_id = ?", current_user.id)
.order("(public IS TRUE) DESC")
.map { |section| SidebarSectionSerializer.new(section, root: false) }
render json: sections
end
def create
sidebar_section =
@ -10,6 +20,15 @@ class SidebarSectionsController < ApplicationController
section_params.merge(user: current_user, sidebar_urls_attributes: links_params),
)
if sidebar_section.public?
StaffActionLogger.new(current_user).log_create_public_sidebar_section(sidebar_section)
MessageBus.publish(
"/refresh-sidebar-sections",
nil,
group_ids: SiteSetting.enable_custom_sidebar_sections_map,
)
end
render json: SidebarSectionSerializer.new(sidebar_section)
rescue ActiveRecord::RecordInvalid => e
render_json_error(e.record.errors.full_messages.first)
@ -21,6 +40,15 @@ class SidebarSectionsController < ApplicationController
sidebar_section.update!(section_params.merge(sidebar_urls_attributes: links_params))
if sidebar_section.public?
StaffActionLogger.new(current_user).log_update_public_sidebar_section(sidebar_section)
MessageBus.publish(
"/refresh-sidebar-sections",
nil,
group_ids: SiteSetting.enable_custom_sidebar_sections_map,
)
end
render json: SidebarSectionSerializer.new(sidebar_section)
rescue ActiveRecord::RecordInvalid => e
render_json_error(e.record.errors.full_messages.first)
@ -32,13 +60,22 @@ class SidebarSectionsController < ApplicationController
sidebar_section = SidebarSection.find_by(id: section_params["id"])
@guardian.ensure_can_delete!(sidebar_section)
sidebar_section.destroy!
if sidebar_section.public?
StaffActionLogger.new(current_user).log_destroy_public_sidebar_section(sidebar_section)
MessageBus.publish(
"/refresh-sidebar-sections",
nil,
group_ids: SiteSetting.enable_custom_sidebar_sections_map,
)
end
render json: SidebarSectionSerializer.new(sidebar_section)
rescue Discourse::InvalidAccess
render json: failed_json, status: 403
end
def section_params
params.permit(:id, :title)
params.permit(:id, :title, :public)
end
def links_params
@ -52,4 +89,11 @@ class SidebarSectionsController < ApplicationController
raise Discourse::InvalidAccess
end
end
private
def check_access_if_public
return true if !params[:public]
raise Discourse::InvalidAccess.new if !guardian.can_create_public_sidebar_section?
end
end

View File

@ -22,6 +22,7 @@ end
# title :string(30) not null
# created_at :datetime not null
# updated_at :datetime not null
# public :boolean default(FALSE), not null
#
# Indexes
#

View File

@ -120,6 +120,9 @@ class UserHistory < ActiveRecord::Base
watched_word_destroy: 98,
delete_group: 99,
permanently_delete_post_revisions: 100,
create_public_sidebar_section: 101,
update_public_sidebar_section: 102,
destroy_public_sidebar_section: 103,
)
end
@ -215,6 +218,9 @@ class UserHistory < ActiveRecord::Base
watched_word_destroy
delete_group
permanently_delete_post_revisions
create_public_sidebar_section
update_public_sidebar_section
destroy_public_sidebar_section
]
end

View File

@ -77,6 +77,13 @@ class CurrentUserSerializer < BasicUserSerializer
has_one :user_option, embed: :object, serializer: CurrentUserOptionSerializer
def sidebar_sections
SidebarSection
.where("public OR user_id = ?", object.id)
.order("(public IS TRUE) DESC")
.map { |section| SidebarSectionSerializer.new(section, root: false) }
end
def groups
owned_group_ids = GroupUser.where(user_id: id, owner: true).pluck(:group_id).to_set

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class SidebarSectionSerializer < ApplicationSerializer
attributes :id, :title, :links, :slug
attributes :id, :title, :links, :slug, :public
def links
object.sidebar_section_links.map(&:linkable)

View File

@ -964,6 +964,32 @@ class StaffActionLogger
)
end
def log_create_public_sidebar_section(section)
UserHistory.create!(
action: UserHistory.actions[:create_public_sidebar_section],
acting_user_id: @admin.id,
subject: section.title,
details: custom_section_details(section),
)
end
def log_update_public_sidebar_section(section)
UserHistory.create!(
action: UserHistory.actions[:update_public_sidebar_section],
acting_user_id: @admin.id,
subject: section.title,
details: custom_section_details(section),
)
end
def log_destroy_public_sidebar_section(section)
UserHistory.create!(
action: UserHistory.actions[:destroy_public_sidebar_section],
acting_user_id: @admin.id,
subject: section.title,
)
end
private
def get_changes(changes)
@ -990,4 +1016,9 @@ class StaffActionLogger
def validate_category(category)
raise Discourse::InvalidParameters.new(:category) unless category && category.is_a?(Category)
end
def custom_section_details(section)
urls = section.sidebar_urls.map { |url| "#{url.name} - #{url.value}" }
"links: #{urls.join(", ")}"
end
end

View File

@ -4373,6 +4373,7 @@ en:
save: "Save"
delete: "Delete"
delete_confirm: "Are you sure you want to delete this section?"
public: "Make this section public and visible to everyone"
links:
icon: "Icon"
name: "Name"
@ -5426,6 +5427,9 @@ en:
delete_group: "delete group"
watched_word_create: "add watched word"
watched_word_destroy: "delete watched word"
create_public_sidebar_section: "create public sidebar section"
update_public_sidebar_section: "update public sidebar section"
destroy_public_sidebar_section: "destroy public sidebar section"
screened_emails:
title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."

View File

@ -1589,7 +1589,7 @@ Discourse::Application.routes.draw do
put "user-status" => "user_status#set"
delete "user-status" => "user_status#clear"
resources :sidebar_sections, only: %i[create update destroy]
resources :sidebar_sections, only: %i[index create update destroy]
get "*url", to: "permalinks#show", constraints: PermalinkConstraint.new
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddPublicToSidebarSections < ActiveRecord::Migration[7.0]
def change
add_column :sidebar_sections, :public, :boolean, null: false, default: false
end
end

View File

@ -1,11 +1,17 @@
# frozen_string_literal: true
module SidebarGuardian
def can_create_public_sidebar_section?
@user.staff?
end
def can_edit_sidebar_section?(sidebar_section)
return @user.staff? if sidebar_section.public?
is_my_own?(sidebar_section)
end
def can_delete_sidebar_section?(sidebar_section)
return @user.staff? if sidebar_section.public?
is_my_own?(sidebar_section)
end
end

View File

@ -2,14 +2,39 @@
RSpec.describe SidebarSectionsController do
fab!(:user) { Fabricate(:user) }
fab!(:admin) { Fabricate(:admin) }
before do
### TODO remove when enable_custom_sidebar_sections SiteSetting is removed
group = Fabricate(:group)
Fabricate(:group_user, group: group, user: user)
Fabricate(:group_user, group: group, user: admin)
SiteSetting.enable_custom_sidebar_sections = group.id.to_s
end
describe "#index" do
fab!(:sidebar_section) { Fabricate(:sidebar_section, title: "private section", user: user) }
fab!(:sidebar_url_1) { Fabricate(:sidebar_url, name: "tags", value: "/tags") }
fab!(:section_link_1) do
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section, linkable: sidebar_url_1)
end
fab!(:sidebar_section_2) do
Fabricate(:sidebar_section, title: "public section", user: admin, public: true)
end
fab!(:section_link_2) do
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section, linkable: sidebar_url_1)
end
it "returns public and private sections" do
sign_in(user)
get "/sidebar_sections.json"
expect(response.status).to eq(200)
expect(response.parsed_body["sidebar_sections"].map { |section| section["title"] }).to eq(
["public section", "private section"],
)
end
end
describe "#create" do
it "is not available for anonymous" do
post "/sidebar_sections.json",
@ -20,7 +45,6 @@ RSpec.describe SidebarSectionsController do
{ icon: "link", name: "tags", value: "/tags" },
],
}
expect(response.status).to eq(403)
end
@ -42,6 +66,8 @@ RSpec.describe SidebarSectionsController do
expect(sidebar_section.title).to eq("custom section")
expect(sidebar_section.user).to eq(user)
expect(sidebar_section.public).to be false
expect(UserHistory.count).to eq(0)
expect(sidebar_section.sidebar_urls.count).to eq(2)
expect(sidebar_section.sidebar_urls.first.icon).to eq("link")
expect(sidebar_section.sidebar_urls.first.name).to eq("categories")
@ -50,6 +76,43 @@ RSpec.describe SidebarSectionsController do
expect(sidebar_section.sidebar_urls.second.name).to eq("tags")
expect(sidebar_section.sidebar_urls.second.value).to eq("/tags")
end
it "does not allow regular user to create public section" do
sign_in(user)
post "/sidebar_sections.json",
params: {
title: "custom section",
public: true,
links: [
{ icon: "link", name: "categories", value: "/categories" },
{ icon: "address-book", name: "tags", value: "/tags" },
],
}
expect(response.status).to eq(403)
end
it "allows admin to create public section" do
sign_in(admin)
post "/sidebar_sections.json",
params: {
title: "custom section",
public: true,
links: [
{ icon: "link", name: "categories", value: "/categories" },
{ icon: "address-book", name: "tags", value: "/tags" },
],
}
expect(response.status).to eq(200)
sidebar_section = SidebarSection.last
expect(sidebar_section.title).to eq("custom section")
expect(sidebar_section.public).to be true
user_history = UserHistory.last
expect(user_history.action).to eq(UserHistory.actions[:create_public_sidebar_section])
expect(user_history.subject).to eq("custom section")
expect(user_history.details).to eq("links: categories - /categories, tags - /tags")
end
end
describe "#update" do
@ -77,12 +140,39 @@ RSpec.describe SidebarSectionsController do
expect(response.status).to eq(200)
expect(sidebar_section.reload.title).to eq("custom section edited")
expect(UserHistory.count).to eq(0)
expect(sidebar_url_1.reload.name).to eq("latest")
expect(sidebar_url_1.value).to eq("/latest")
expect { section_link_2.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect { sidebar_url_2.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it "allows admin to update public section and links" do
sign_in(admin)
sidebar_section.update!(user: admin, public: true)
put "/sidebar_sections/#{sidebar_section.id}.json",
params: {
title: "custom section edited",
links: [
{ icon: "link", id: sidebar_url_1.id, name: "latest", value: "/latest" },
{ icon: "link", id: sidebar_url_2.id, name: "tags", value: "/tags", _destroy: "1" },
],
}
expect(response.status).to eq(200)
expect(sidebar_section.reload.title).to eq("custom section edited")
expect(sidebar_url_1.reload.name).to eq("latest")
expect(sidebar_url_1.value).to eq("/latest")
expect { section_link_2.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect { sidebar_url_2.reload }.to raise_error(ActiveRecord::RecordNotFound)
user_history = UserHistory.last
expect(user_history.action).to eq(UserHistory.actions[:update_public_sidebar_section])
expect(user_history.subject).to eq("custom section edited")
expect(user_history.details).to eq("links: latest - /latest")
end
it "doesn't allow to edit other's sections" do
sidebar_section_2 = Fabricate(:sidebar_section)
sidebar_url_3 = Fabricate(:sidebar_url, name: "other_tags", value: "/tags")
@ -97,6 +187,20 @@ RSpec.describe SidebarSectionsController do
expect(response.status).to eq(403)
end
it "doesn't allow to edit public sections" do
sign_in(user)
sidebar_section.update!(public: true)
put "/sidebar_sections/#{sidebar_section.id}.json",
params: {
title: "custom section edited",
links: [
{ icon: "link", id: sidebar_url_1.id, name: "latest", value: "/latest" },
{ icon: "link", id: sidebar_url_2.id, name: "tags", value: "/tags", _destroy: "1" },
],
}
expect(response.status).to eq(403)
end
it "doesn't allow to edit other's links" do
sidebar_url_3 = Fabricate(:sidebar_url, name: "other_tags", value: "/tags")
Fabricate(
@ -127,6 +231,22 @@ RSpec.describe SidebarSectionsController do
expect(response.status).to eq(200)
expect { sidebar_section.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(UserHistory.count).to eq(0)
end
it "allows admin to delete public section" do
sign_in(admin)
sidebar_section.update!(user: admin, public: true)
delete "/sidebar_sections/#{sidebar_section.id}.json"
expect(response.status).to eq(200)
expect { sidebar_section.reload }.to raise_error(ActiveRecord::RecordNotFound)
user_history = UserHistory.last
expect(user_history.action).to eq(UserHistory.actions[:destroy_public_sidebar_section])
expect(user_history.subject).to eq("Sidebar section")
end
it "doesn't allow to delete other's sidebar section" do
@ -136,5 +256,13 @@ RSpec.describe SidebarSectionsController do
expect(response.status).to eq(403)
end
it "doesn't allow to delete public sidebar section" do
sign_in(user)
sidebar_section.update!(public: true)
delete "/sidebar_sections/#{sidebar_section.id}.json"
expect(response.status).to eq(403)
end
end
end

View File

@ -2,6 +2,7 @@
describe "Custom sidebar sections", type: :system, js: true do
fab!(:user) { Fabricate(:user) }
fab!(:admin) { Fabricate(:admin) }
let(:section_modal) { PageObjects::Modals::SidebarSectionForm.new }
let(:sidebar) { PageObjects::Components::Sidebar.new }
@ -9,6 +10,7 @@ describe "Custom sidebar sections", type: :system, js: true do
### TODO remove when enable_custom_sidebar_sections SiteSetting is removed
group = Fabricate(:group)
Fabricate(:group_user, group: group, user: user)
Fabricate(:group_user, group: group, user: admin)
SiteSetting.enable_custom_sidebar_sections = group.id.to_s
sign_in user
end
@ -55,6 +57,23 @@ describe "Custom sidebar sections", type: :system, js: true do
expect(page).not_to have_link("Sidebar Categories")
end
it "does not allow the user to edit public section" do
sidebar_section = Fabricate(:sidebar_section, title: "Public section", user: user, public: true)
sidebar_url_1 = Fabricate(:sidebar_url, name: "Sidebar Tags", value: "/tags")
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section, linkable: sidebar_url_1)
sidebar_url_2 = Fabricate(:sidebar_url, name: "Sidebar Categories", value: "/categories")
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section, linkable: sidebar_url_2)
visit("/latest")
expect(page).to have_button("Public section")
find(".sidebar-section-public-section").hover
expect(page).not_to have_css(
".sidebar-section-public-section button.sidebar-section-header-button",
)
expect(page).not_to have_css(".sidebar-section-public-section .d-icon-globe")
end
it "allows the user to delete custom section" do
sidebar_section = Fabricate(:sidebar_section, title: "My section", user: user)
sidebar_url_1 = Fabricate(:sidebar_url, name: "tags", value: "/tags")
@ -69,4 +88,31 @@ describe "Custom sidebar sections", type: :system, js: true do
expect(page).not_to have_button("My section")
end
it "allows admin to create, edit and delete public section" do
sign_in admin
visit("/latest")
sidebar.open_new_custom_section
section_modal.fill_name("Public section")
section_modal.fill_link("Sidebar Tags", "/tags")
section_modal.mark_as_public
section_modal.save
expect(page).to have_button("Public section")
expect(page).to have_link("Sidebar Tags")
expect(page).to have_css(".sidebar-section-public-section .d-icon-globe")
sidebar.edit_custom_section("Public section")
section_modal.fill_name("Edited public section")
section_modal.save
expect(page).to have_button("Edited public section")
sidebar.edit_custom_section("Edited public section")
section_modal.delete
section_modal.confirm_delete
expect(page).not_to have_button("Edited public section")
end
end

View File

@ -12,6 +12,10 @@ module PageObjects
fill_in "link-url", with: url, match: :first
end
def mark_as_public
find(".modal .mark-public").click
end
def remove_last_link
all(".delete-link").last.click
end