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:
parent
5d28cb709a
commit
84a87a703c
|
@ -7,7 +7,7 @@
|
|||
<a
|
||||
href={{@href}}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
target={{or @target "_blank"}}
|
||||
class={{this.classNames}}
|
||||
title={{@title}}
|
||||
>
|
||||
|
|
|
@ -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>
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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}}
|
||||
|
|
|
@ -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",
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
|
@ -407,6 +407,8 @@ const User = RestModel.extend({
|
|||
});
|
||||
},
|
||||
|
||||
sidebarSections: alias("sidebar_sections"),
|
||||
|
||||
sidebarTagNames: mapBy("sidebarTags", "name"),
|
||||
sidebarListDestination: readOnly("sidebar_list_destination"),
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
#
|
|
@ -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)) ||
|
||||
|
@ -26,12 +30,13 @@ end
|
|||
#
|
||||
# Table name: sidebar_section_links
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# user_id :integer not null
|
||||
# linkable_id :integer not null
|
||||
# linkable_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :bigint not null, primary key
|
||||
# user_id :integer not null
|
||||
# linkable_id :integer not null
|
||||
# linkable_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# sidebar_section_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
|
|
@ -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
|
||||
#
|
|
@ -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)
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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?
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:sidebar_section) do
|
||||
title "Sidebar section"
|
||||
user
|
||||
end
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:sidebar_url) do
|
||||
name "tags"
|
||||
value "/tags"
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue