DEV: configurable custom sidebar sections (#20057)

Allows users to configure their own custom sidebar sections with links withing Discourse instance. Links can be passed as relative path, for example "/tags" or full URL.

Only path is saved in DB, so when Discourse domain is changed, links will be still valid.

Feature is hidden behind SiteSetting.enable_custom_sidebar_sections. This hidden setting determines the group which members have access to this new feature.
This commit is contained in:
Krzysztof Kotlarek 2023-02-03 14:44:40 +11:00 committed by GitHub
parent 5d28cb709a
commit 84a87a703c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 973 additions and 19 deletions

View File

@ -7,7 +7,7 @@
<a
href={{@href}}
rel="noopener noreferrer"
target="_blank"
target={{or @target "_blank"}}
class={{this.classNames}}
title={{@title}}
>

View File

@ -0,0 +1,33 @@
<div class="sidebar-custom-sections">
{{#each this.currentUser.sidebarSections as |section|}}
<Sidebar::Section
@sectionName={{section.slug}}
@headerLinkText={{section.title}}
@collapsable={{true}}
@headerActions={{array
(hash
action=(action this.editSection section)
title=(i18n "sidebar.sections.custom.edit")
)
}}
@headerActionsIcon="pencil-alt"
>
{{#each section.links as |link|}}
<Sidebar::SectionLink
@linkName={{link.name}}
@href={{link.value}}
@target="_self"
@content={{link.name}}
@class={{link.class}}
/>
{{/each}}
</Sidebar::Section>
{{/each}}
<DButton
@icon="plus"
@action={{action this.addSection}}
@class="btn-flat add-section"
@title="sidebar.sections.custom.add"
/>
</div>

View File

@ -0,0 +1,31 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service";
export default class SidebarUserCustomSections extends Component {
@service currentUser;
constructor() {
super(...arguments);
this.sections.forEach((section) => {
section.links.forEach((link) => {
link.class = window.location.pathname === link.value ? "active" : "";
});
});
}
get sections() {
return this.currentUser.sidebar_sections || [];
}
@action
editSection(section) {
showModal("sidebar-section-form", { model: section });
}
addSection() {
showModal("sidebar-section-form");
}
}

View File

@ -1,5 +1,8 @@
<div class="sidebar-sections">
<Sidebar::User::CommunitySection @collapsable={{@collapsableSections}} />
{{#if this.currentUser.custom_sidebar_sections_enabled}}
<Sidebar::User::CustomSections />
{{/if}}
<Sidebar::User::CategoriesSection @collapsable={{@collapsableSections}} />
{{#if this.currentUser.display_sidebar_tags}}

View File

@ -0,0 +1,234 @@
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { ajax } from "discourse/lib/ajax";
import { isEmpty } from "@ember/utils";
import { extractError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service";
import I18n from "I18n";
import { sanitize } from "discourse/lib/text";
import { tracked } from "@glimmer/tracking";
import { A } from "@ember/array";
class Section {
@tracked title;
@tracked links;
constructor({ title, links, id }) {
this.title = title;
this.links = links;
this.id = id;
}
get valid() {
const validLinks =
this.links.length > 0 && this.links.every((link) => link.valid);
return this.validTitle && validLinks;
}
get validTitle() {
return !isEmpty(this.title);
}
get titleCssClass() {
return this.title === undefined || this.validTitle ? "" : "warning";
}
}
class SectionLink {
@tracked name;
@tracked value;
@tracked _destroy;
constructor({ router, name, value, id }) {
this.router = router;
this.name = name;
this.value = value ? `${this.protocolAndHost}${value}` : value;
this.id = id;
}
get protocolAndHost() {
return window.location.protocol + "//" + window.location.host;
}
get path() {
return this.value?.replace(this.protocolAndHost, "");
}
get valid() {
return this.validName && this.validValue;
}
get validName() {
return !isEmpty(this.name);
}
get nameCssClass() {
return this.name === undefined || this.validName ? "" : "warning";
}
get validValue() {
return (
!isEmpty(this.value) &&
(this.value.startsWith(this.protocolAndHost) ||
this.value.startsWith("/")) &&
this.path &&
this.router.recognize(this.path).name !== "unknown"
);
}
get valueCssClass() {
return this.value === undefined || this.validValue ? "" : "warning";
}
}
export default Controller.extend(ModalFunctionality, {
dialog: service(),
router: service(),
onShow() {
this.setProperties({
flashText: null,
flashClass: null,
});
this.model = this.initModel();
},
onClose() {
this.model = null;
},
initModel() {
if (this.model) {
return new Section({
title: this.model.title,
links: A(
this.model.links.map(
(link) =>
new SectionLink({
router: this.router,
name: link.name,
value: link.value,
id: link.id,
})
)
),
id: this.model.id,
});
} else {
return new Section({
links: A([new SectionLink({ router: this.router })]),
});
}
},
create() {
return ajax(`/sidebar_sections`, {
type: "POST",
contentType: "application/json",
dataType: "json",
data: JSON.stringify({
title: this.model.title,
links: this.model.links.map((link) => {
return {
name: link.name,
value: link.path,
};
}),
}),
})
.then((data) => {
this.currentUser.sidebar_sections.pushObject(data.sidebar_section);
this.send("closeModal");
})
.catch((e) =>
this.setProperties({
flashText: sanitize(extractError(e)),
flashClass: "error",
})
);
},
update() {
return ajax(`/sidebar_sections/${this.model.id}`, {
type: "PUT",
contentType: "application/json",
dataType: "json",
data: JSON.stringify({
title: this.model.title,
links: this.model.links.map((link) => {
return {
id: link.id,
name: link.name,
value: link.path,
_destroy: link._destroy,
};
}),
}),
})
.then((data) => {
const newSidebarSections = this.currentUser.sidebar_sections.map(
(section) => {
if (section.id === data["sidebar_section"].id) {
return data["sidebar_section"];
}
return section;
}
);
this.currentUser.set("sidebar_sections", newSidebarSections);
this.send("closeModal");
})
.catch((e) =>
this.setProperties({
flashText: sanitize(extractError(e)),
flashClass: "error",
})
);
},
get activeLinks() {
return this.model.links.filter((link) => !link._destroy);
},
actions: {
addLink() {
this.model.links.pushObject(new SectionLink({ router: this.router }));
},
deleteLink(link) {
if (link.id) {
link._destroy = "1";
} else {
this.model.links.removeObject(link);
}
},
save() {
this.model.id ? this.update() : this.create();
},
delete() {
return this.dialog.yesNoConfirm({
message: I18n.t("sidebar.sections.custom.delete_confirm"),
didConfirm: () => {
return ajax(`/sidebar_sections/${this.model.id}`, {
type: "DELETE",
})
.then((data) => {
const newSidebarSections =
this.currentUser.sidebar_sections.filter((section) => {
return section.id !== data["sidebar_section"].id;
});
this.currentUser.set("sidebar_sections", newSidebarSections);
this.send("closeModal");
})
.catch((e) =>
this.setProperties({
flashText: sanitize(extractError(e)),
flashClass: "error",
})
);
},
});
},
},
});

View File

@ -407,6 +407,8 @@ const User = RestModel.extend({
});
},
sidebarSections: alias("sidebar_sections"),
sidebarTagNames: mapBy("sidebarTags", "name"),
sidebarListDestination: readOnly("sidebar_list_destination"),

View File

@ -0,0 +1,79 @@
{{#if this.flashText}}
<div id="modal-alert" role="alert" class="alert alert-{{this.flashClass}}">
{{this.flashText}}
</div>
{{/if}}
<DModalBody @title="sidebar.sections.custom.add">
<form class="form-horizontal">
<div class="input-group">
<label for="section-name">{{i18n "sidebar.sections.custom.name"}}</label>
<Input
name="section-name"
@type="text"
@value={{this.model.title}}
class={{this.model.titleCssClass}}
{{on "input" (action (mut this.model.title) value="target.value")}}
/>
</div>
{{#each this.activeLinks as |link|}}
<div class="row-wrapper">
<div class="input-group">
<label for="link-name">{{i18n
"sidebar.sections.custom.links.name"
}}</label>
<Input
name="link-name"
@type="text"
@value={{link.name}}
class={{link.nameCssClass}}
{{on "input" (action (mut link.name) value="target.value")}}
/>
</div>
<div class="input-group">
<label for="link-url">{{i18n
"sidebar.sections.custom.links.points_to"
}}</label>
<Input
name="link-url"
@type="text"
@value={{link.value}}
class={{link.valueCssClass}}
{{on "input" (action (mut link.value) value="target.value")}}
/>
</div>
<DButton
@icon="times"
@action={{action "deleteLink" link}}
@class="btn-flat delete-link"
@title="sidebar.sections.custom.links.delete"
/>
</div>
{{/each}}
<DButton
@action={{action "addLink"}}
@class="btn-flat btn-text add-link"
@title="sidebar.sections.custom.links.add"
@label="sidebar.sections.custom.links.add"
/>
</form>
</DModalBody>
<div class="modal-footer">
<DButton
@id="save-section"
@action={{action "save"}}
@class="btn-primary"
@icon="plus"
@label="sidebar.sections.custom.save"
@disabled={{not this.model.valid}}
/>
{{#if this.model.id}}
<DButton
@icon="trash-alt"
@id="delete-section"
@class="btn-danger delete"
@action={{action "delete"}}
@label="sidebar.sections.custom.delete"
/>
{{/if}}
</div>

View File

@ -110,3 +110,79 @@
}
}
}
.sidebar-custom-sections {
.btn-flat.add-section {
margin-left: calc(var(--d-sidebar-section-link-prefix-width) / 2);
margin-right: calc(var(--d-sidebar-section-link-prefix-width) / 2);
width: calc(100% - var(--d-sidebar-section-link-prefix-width));
svg {
height: 0.75em;
width: 0.75em;
padding-left: 0.5em;
padding-right: 0.5em;
}
&:before,
&:after {
content: "";
flex: 1 1;
border-bottom: 1px solid var(--primary-low-mid);
margin: auto;
}
&:hover {
background: var(--d-sidebar-highlight-color);
border-radius: 5px;
&:before,
&:after {
border-bottom: 1px solid var(--primary-high);
}
}
}
a.sidebar-section-link {
padding-left: calc(
var(--d-sidebar-section-link-prefix-width) +
var(--d-sidebar-section-link-prefix-margin-right) +
var(--d-sidebar-row-horizontal-padding)
);
}
}
.sidebar-section-form-modal {
.modal-inner-container {
width: var(--modal-max-width);
}
input {
width: 100%;
}
input.warning {
border: 1px solid var(--danger);
}
.row-wrapper {
display: grid;
grid-template-columns: auto auto 2em;
gap: 1em;
margin-top: 1em;
}
.delete-link {
height: 1em;
align-self: end;
margin-bottom: 0.75em;
margin-right: 1em;
}
.btn-flat.add-link {
float: right;
margin-top: 1em;
margin-right: -0.5em;
&:active,
&:focus {
background: none;
}
}
.modal-footer {
display: flex;
justify-content: space-between;
.delete {
margin-right: 0;
}
}
}

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
class SidebarSectionsController < ApplicationController
requires_login
before_action :check_if_member_of_group
def create
sidebar_section =
SidebarSection.create!(
section_params.merge(user: current_user, sidebar_urls_attributes: links_params),
)
render json: SidebarSectionSerializer.new(sidebar_section)
rescue ActiveRecord::RecordInvalid => e
render_json_error(e.record.errors.full_messages.first)
end
def update
sidebar_section = SidebarSection.find_by(id: section_params["id"])
@guardian.ensure_can_edit!(sidebar_section)
sidebar_section.update!(section_params.merge(sidebar_urls_attributes: links_params))
render json: SidebarSectionSerializer.new(sidebar_section)
rescue ActiveRecord::RecordInvalid => e
render_json_error(e.record.errors.full_messages.first)
rescue Discourse::InvalidAccess
render json: failed_json, status: 403
end
def destroy
sidebar_section = SidebarSection.find_by(id: section_params["id"])
@guardian.ensure_can_delete!(sidebar_section)
sidebar_section.destroy!
render json: SidebarSectionSerializer.new(sidebar_section)
rescue Discourse::InvalidAccess
render json: failed_json, status: 403
end
def section_params
params.permit(:id, :title)
end
def links_params
params.permit(links: %i[name value id _destroy])["links"]
end
def check_if_member_of_group
### TODO remove when enable_custom_sidebar_sections SiteSetting is removed
if !SiteSetting.enable_custom_sidebar_sections.present? ||
!current_user.in_any_groups?(SiteSetting.enable_custom_sidebar_sections_map)
raise Discourse::InvalidAccess
end
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
class SidebarSection < ActiveRecord::Base
belongs_to :user
has_many :sidebar_section_links, dependent: :destroy
has_many :sidebar_urls,
through: :sidebar_section_links,
source: :linkable,
source_type: "SidebarUrl"
accepts_nested_attributes_for :sidebar_urls, allow_destroy: true
validates :title, presence: true, uniqueness: { scope: %i[user_id] }
end
# == Schema Information
#
# Table name: sidebar_sections
#
# id :bigint not null, primary key
# user_id :integer not null
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_sidebar_sections_on_user_id_and_title (user_id,title) UNIQUE
#

View File

@ -3,13 +3,17 @@
class SidebarSectionLink < ActiveRecord::Base
belongs_to :user
belongs_to :linkable, polymorphic: true
belongs_to :sidebar_section
validates :user_id, presence: true, uniqueness: { scope: %i[linkable_type linkable_id] }
validates :linkable_id, presence: true
validates :linkable_type, presence: true
validate :ensure_supported_linkable_type, if: :will_save_change_to_linkable_type?
SUPPORTED_LINKABLE_TYPES = %w[Category Tag]
SUPPORTED_LINKABLE_TYPES = %w[Category Tag SidebarUrl]
before_validation { self.user_id ||= self.sidebar_section&.user_id }
after_destroy { self.linkable.destroy! if self.linkable_type == "SidebarUrl" }
private def ensure_supported_linkable_type
if (!SUPPORTED_LINKABLE_TYPES.include?(self.linkable_type)) ||
@ -32,6 +36,7 @@ end
# linkable_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# sidebar_section_id :integer
#
# Indexes
#

27
app/models/sidebar_url.rb Normal file
View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class SidebarUrl < ActiveRecord::Base
validates :name, presence: true
validates :value, presence: true
validate :path_validator
def path_validator
Rails.application.routes.recognize_path(value)
rescue ActionController::RoutingError
errors.add(
:value,
I18n.t("activerecord.errors.models.sidebar_section_link.attributes.linkable_type.invalid"),
)
end
end
# == Schema Information
#
# Table name: sidebar_urls
#
# id :bigint not null, primary key
# name :string not null
# value :string not null
# created_at :datetime not null
# updated_at :datetime not null
#

View File

@ -82,6 +82,7 @@ class User < ActiveRecord::Base
has_many :muted_user_records, class_name: "MutedUser", dependent: :delete_all
has_many :ignored_user_records, class_name: "IgnoredUser", dependent: :delete_all
has_many :do_not_disturb_timings, dependent: :delete_all
has_many :sidebar_sections, dependent: :destroy
has_one :user_status, dependent: :destroy
# dependent deleting handled via before_destroy (special cases)

View File

@ -44,6 +44,18 @@ module UserSidebarMixin
sidebar_navigation_menu?
end
def sidebar_sections
object
.sidebar_sections
.order(created_at: :asc)
.includes(sidebar_section_links: :linkable)
.map { |section| SidebarSectionSerializer.new(section, root: false) }
end
def include_sidebar_sections?
sidebar_navigation_menu?
end
private
def sidebar_navigation_menu?

View File

@ -69,7 +69,9 @@ class CurrentUserSerializer < BasicUserSerializer
:display_sidebar_tags,
:sidebar_tags,
:sidebar_category_ids,
:sidebar_list_destination
:sidebar_list_destination,
:sidebar_sections,
:custom_sidebar_sections_enabled
delegate :user_stat, to: :object, private: true
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
@ -309,4 +311,11 @@ class CurrentUserSerializer < BasicUserSerializer
false
end
end
def custom_sidebar_sections_enabled
if SiteSetting.enable_custom_sidebar_sections.present?
object.in_any_groups?(SiteSetting.enable_custom_sidebar_sections_map)
else
false
end
end
end

View File

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

View File

@ -4362,6 +4362,18 @@ en:
all_categories: "All categories"
all_tags: "All tags"
sections:
custom:
add: "Add custom section"
edit: "Edit custom section"
name: "Section title"
save: "Save"
delete: "Delete"
delete_confirm: "Are you sure you want to delete this section?"
links:
name: "Link name"
points_to: "Points to"
add: "Add link"
delete: "Delete link"
about:
header_link_text: "About"
messages:

View File

@ -1588,6 +1588,8 @@ 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]
get "*url", to: "permalinks#show", constraints: PermalinkConstraint.new
end
end

View File

@ -2118,6 +2118,14 @@ navigation:
enable_new_notifications_menu:
default: false
validator: "EnableNewNotificationsMenuValidator"
enable_custom_sidebar_sections:
client: true
type: group_list
list_type: compact
default: ""
allow_any: false
refresh: true
hidden: true
embedding:
embed_by_username:

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class CreateSidebarSections < ActiveRecord::Migration[7.0]
def change
create_table :sidebar_sections do |t|
t.integer :user_id, null: false
t.string :title, null: false
t.timestamps
end
add_index :sidebar_sections, %i[user_id title], unique: true
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class CreateSidebarUrl < ActiveRecord::Migration[7.0]
def change
create_table :sidebar_urls do |t|
t.string :name, null: false
t.string :value, null: false
t.timestamps
end
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddSidebarSectionIdToSidebarSectionLinks < ActiveRecord::Migration[7.0]
def change
add_column :sidebar_section_links, :sidebar_section_id, :integer, index: true
end
end

View File

@ -1,26 +1,28 @@
# frozen_string_literal: true
require "guardian/bookmark_guardian"
require "guardian/category_guardian"
require "guardian/ensure_magic"
require "guardian/group_guardian"
require "guardian/post_guardian"
require "guardian/bookmark_guardian"
require "guardian/post_revision_guardian"
require "guardian/sidebar_guardian"
require "guardian/tag_guardian"
require "guardian/topic_guardian"
require "guardian/user_guardian"
require "guardian/post_revision_guardian"
require "guardian/group_guardian"
require "guardian/tag_guardian"
# The guardian is responsible for confirming access to various site resources and operations
class Guardian
include EnsureMagic
include CategoryGuardian
include PostGuardian
include BookmarkGuardian
include CategoryGuardian
include EnsureMagic
include GroupGuardian
include PostGuardian
include PostRevisionGuardian
include SidebarGuardian
include TagGuardian
include TopicGuardian
include UserGuardian
include PostRevisionGuardian
include GroupGuardian
include TagGuardian
class AnonymousUser
def blank?

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module SidebarGuardian
def can_edit_sidebar_section?(sidebar_section)
is_my_own?(sidebar_section)
end
def can_delete_sidebar_section?(sidebar_section)
is_my_own?(sidebar_section)
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
Fabricator(:sidebar_section) do
title "Sidebar section"
user
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
Fabricator(:sidebar_url) do
name "tags"
value "/tags"
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
RSpec.describe SidebarUrl do
it "validates path" do
expect(SidebarUrl.new(name: "categories", value: "/categories").valid?).to eq(true)
expect(SidebarUrl.new(name: "categories", value: "/invalid_path").valid?).to eq(false)
end
end

View File

@ -0,0 +1,138 @@
# frozen_string_literal: true
RSpec.describe SidebarSectionsController do
fab!(:user) { Fabricate(:user) }
before do
### TODO remove when enable_custom_sidebar_sections SiteSetting is removed
group = Fabricate(:group)
Fabricate(:group_user, group: group, user: user)
SiteSetting.enable_custom_sidebar_sections = group.id.to_s
end
describe "#create" do
it "is not available for anonymous" do
post "/sidebar_sections.json",
params: {
title: "custom section",
links: [
{ name: "categories", value: "/categories" },
{ name: "tags", value: "/tags" },
],
}
expect(response.status).to eq(403)
end
it "creates custom section for user" do
sign_in(user)
post "/sidebar_sections.json",
params: {
title: "custom section",
links: [
{ name: "categories", value: "/categories" },
{ name: "tags", value: "/tags" },
],
}
expect(response.status).to eq(200)
expect(SidebarSection.count).to eq(1)
sidebar_section = SidebarSection.last
expect(sidebar_section.title).to eq("custom section")
expect(sidebar_section.user).to eq(user)
expect(sidebar_section.sidebar_urls.count).to eq(2)
expect(sidebar_section.sidebar_urls.first.name).to eq("categories")
expect(sidebar_section.sidebar_urls.first.value).to eq("/categories")
expect(sidebar_section.sidebar_urls.second.name).to eq("tags")
expect(sidebar_section.sidebar_urls.second.value).to eq("/tags")
end
end
describe "#update" do
fab!(:sidebar_section) { Fabricate(:sidebar_section, user: user) }
fab!(:sidebar_url_1) { Fabricate(:sidebar_url, name: "tags", value: "/tags") }
fab!(:sidebar_url_2) { Fabricate(:sidebar_url, name: "categories", value: "/categories") }
fab!(:section_link_1) do
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section, linkable: sidebar_url_1)
end
fab!(:section_link_2) do
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section, linkable: sidebar_url_2)
end
it "allows user to update their own section and links" do
sign_in(user)
put "/sidebar_sections/#{sidebar_section.id}.json",
params: {
title: "custom section edited",
links: [
{ id: sidebar_url_1.id, name: "latest", value: "/latest" },
{ 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)
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")
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section_2, linkable: sidebar_url_3)
sign_in(user)
put "/sidebar_sections/#{sidebar_section_2.id}.json",
params: {
title: "custom section edited",
links: [{ id: sidebar_url_3.id, name: "takeover", value: "/categories" }],
}
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(
:sidebar_section_link,
sidebar_section: Fabricate(:sidebar_section),
linkable: sidebar_url_3,
)
sign_in(user)
put "/sidebar_sections/#{sidebar_section.id}.json",
params: {
title: "custom section edited",
links: [{ id: sidebar_url_3.id, name: "takeover", value: "/categories" }],
}
expect(response.status).to eq(404)
expect(sidebar_url_3.reload.name).to eq("other_tags")
end
end
describe "#destroy" do
fab!(:sidebar_section) { Fabricate(:sidebar_section, user: user) }
it "allows user to delete their own section" do
sign_in(user)
delete "/sidebar_sections/#{sidebar_section.id}.json"
expect(response.status).to eq(200)
expect { sidebar_section.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it "doesn't allow to delete other's sidebar section" do
sidebar_section_2 = Fabricate(:sidebar_section)
sign_in(user)
delete "/sidebar_sections/#{sidebar_section_2.id}.json"
expect(response.status).to eq(403)
end
end
end

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
describe "Custom sidebar sections", type: :system, js: true do
fab!(:user) { Fabricate(:user) }
let(:section_modal) { PageObjects::Modals::SidebarSectionForm.new }
let(:sidebar) { PageObjects::Components::Sidebar.new }
before do
### TODO remove when enable_custom_sidebar_sections SiteSetting is removed
group = Fabricate(:group)
Fabricate(:group_user, group: group, user: user)
SiteSetting.enable_custom_sidebar_sections = group.id.to_s
sign_in user
end
it "allows the user to create custom section" do
visit("/latest")
sidebar.open_new_custom_section
expect(section_modal).to be_visible
expect(section_modal).to have_disabled_save
section_modal.fill_name("My section")
section_modal.fill_link("Sidebar Tags", "/tags")
expect(section_modal).to have_enabled_save
section_modal.save
expect(page).to have_button("My section")
expect(page).to have_link("Sidebar Tags")
end
it "allows the user to edit custom section" do
sidebar_section = Fabricate(:sidebar_section, title: "My section", user: user)
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")
sidebar.edit_custom_section("My section")
section_modal.fill_name("Edited section")
section_modal.fill_link("Edited Tags", "/tags")
section_modal.remove_last_link
section_modal.save
expect(page).to have_button("Edited section")
expect(page).to have_link("Edited Tags")
expect(page).not_to have_link("Sidebar Categories")
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")
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section, linkable: sidebar_url_1)
visit("/latest")
sidebar.edit_custom_section("My section")
section_modal.delete
section_modal.confirm_delete
expect(page).not_to have_button("My section")
end
end

View File

@ -10,6 +10,15 @@ module PageObjects
def has_category_section_link?(category)
page.has_link?(category.name, class: "sidebar-section-link")
end
def open_new_custom_section
find("button.add-section").click
end
def edit_custom_section(name)
find(".sidebar-section-#{name.parameterize}").hover
find(".sidebar-section-#{name.parameterize} button.sidebar-section-header-button").click
end
end
end
end

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
module PageObjects
module Modals
class SidebarSectionForm < PageObjects::Modals::Base
def fill_name(name)
fill_in "section-name", with: name
end
def fill_link(name, url)
fill_in "link-name", with: name, match: :first
fill_in "link-url", with: url, match: :first
end
def remove_last_link
all(".delete-link").last.click
end
def delete
find("#delete-section").click
end
def confirm_delete
find(".dialog-container .btn-primary").click
end
def save
find("#save-section").click
end
def visible?
page.has_css?(".sidebar-section-form-modal")
end
def has_disabled_save?
find_button("Save", disabled: true)
end
def has_enabled_save?
find_button("Save", disabled: false)
end
end
end
end