FEATURE: modal for admins to edit Community section ()

Allow admins to edit Community section. This includes drag and drop reorder, change names, delete and reset to default.

Visual improvements introduced in edit community section modal are available in edit custom section form as well. For example:
- drag and drop links to change their position;
- smaller icon picker.
This commit is contained in:
Krzysztof Kotlarek 2023-05-29 15:20:23 +10:00 committed by GitHub
parent 7d9a823a55
commit 9f78ff5572
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 651 additions and 202 deletions

@ -7,51 +7,50 @@
@class={{this.section.dragCss}}
>
{{#each this.section.links as |link|}}
{{#if link.shouldDisplay}}
{{#if link.external}}
<Sidebar::SectionLink
@linkName={{link.name}}
@content={{replace-emoji link.text}}
@prefixType="icon"
@prefixValue={{link.prefixValue}}
@href={{link.value}}
@class={{link.linkDragCss}}
{{draggable
{{#if link.external}}
<Sidebar::SectionLink
@shouldDisplay={{link.shouldDisplay}}
@linkName={{link.name}}
@content={{replace-emoji link.text}}
@prefixType="icon"
@prefixValue={{link.prefixValue}}
@href={{link.value}}
@class={{link.linkDragCss}}
{{draggable
didStartDrag=link.didStartDrag
didEndDrag=link.didEndDrag
dragMove=link.dragMove
}}
/>
{{else}}
<Sidebar::SectionLink
@shouldDisplay={{link.shouldDisplay}}
@href={{link.href}}
@title={{link.title}}
@linkName={{link.name}}
@route={{link.route}}
@model={{link.model}}
@models={{link.models}}
@query={{link.query}}
@content={{replace-emoji link.text}}
@badgeText={{link.badgeText}}
@prefixType="icon"
@prefixValue={{link.prefixValue}}
@suffixCSSClass={{link.suffixCSSClass}}
@suffixValue={{link.suffixValue}}
@suffixType={{link.suffixType}}
@currentWhen={{link.currentWhen}}
@class={{link.linkDragCss}}
{{(if
link.didStartDrag
(modifier
"draggable"
didStartDrag=link.didStartDrag
didEndDrag=link.didEndDrag
dragMove=link.dragMove
}}
/>
{{else}}
<Sidebar::SectionLink
@shouldDisplay={{link.shouldDisplay}}
@href={{link.href}}
@title={{link.title}}
@linkName={{link.name}}
@route={{link.route}}
@model={{link.model}}
@models={{link.models}}
@query={{link.query}}
@content={{replace-emoji link.text}}
@badgeText={{link.badgeText}}
@prefixType="icon"
@prefixValue={{link.prefixValue}}
@suffixCSSClass={{link.suffixCSSClass}}
@suffixValue={{link.suffixValue}}
@suffixType={{link.suffixType}}
@currentWhen={{link.currentWhen}}
@class={{link.linkDragCss}}
{{(if
link.didStartDrag
(modifier
"draggable"
didStartDrag=link.didStartDrag
didEndDrag=link.didEndDrag
dragMove=link.dragMove
)
)}}
/>
{{/if}}
)
)}}
/>
{{/if}}
{{/each}}

@ -1,15 +1,29 @@
<Sidebar::SectionLink
@shouldDisplay={{@sectionLink.shouldDisplay}}
@linkName={{@sectionLink.name}}
@route={{@sectionLink.route}}
@href={{@sectionLink.href}}
@query={{@sectionLink.query}}
@title={{@sectionLink.title}}
@content={{@sectionLink.text}}
@currentWhen={{@sectionLink.currentWhen}}
@badgeText={{@sectionLink.badgeText}}
@model={{@sectionLink.model}}
@models={{@sectionLink.models}}
@prefixType={{@sectionLink.prefixType}}
@prefixValue={{@sectionLink.prefixValue}}
/>
{{#if @sectionLink.external}}
<Sidebar::SectionLink
@shouldDisplay={{@sectionLink.shouldDisplay}}
@linkName={{@sectionLink.name}}
@content={{replace-emoji @sectionLink.text}}
@prefixType="icon"
@prefixValue={{@sectionLink.prefixValue}}
@href={{@sectionLink.value}}
/>
{{else}}
<Sidebar::SectionLink
@shouldDisplay={{@sectionLink.shouldDisplay}}
@href={{@sectionLink.href}}
@title={{@sectionLink.title}}
@linkName={{@sectionLink.name}}
@route={{@sectionLink.route}}
@model={{@sectionLink.model}}
@models={{@sectionLink.models}}
@query={{@sectionLink.query}}
@content={{replace-emoji @sectionLink.text}}
@badgeText={{@sectionLink.badgeText}}
@prefixType="icon"
@prefixValue={{@sectionLink.prefixValue}}
@suffixCSSClass={{@sectionLink.suffixCSSClass}}
@suffixValue={{@sectionLink.suffixValue}}
@suffixType={{@sectionLink.suffixType}}
@currentWhen={{@sectionLink.currentWhen}}
/>
{{/if}}

@ -0,0 +1,72 @@
<div
class={{concat-class
"sidebar-section-form-link"
"row-wrapper"
this.dragCssClass
}}
draggable="true"
{{on "dragstart" this.dragHasStarted}}
{{on "dragover" this.dragOver}}
{{on "dragenter" this.dragEnter}}
{{on "dragleave" this.dragLeave}}
{{on "dragend" this.dragEnd}}
{{on "drop" this.dropItem}}
>
<div class="draggable" data-link-name={{@link.name}}>
{{d-icon "grip-lines"}}
</div>
<div class="input-group">
<IconPicker
@name="icon"
@value={{@link.icon}}
@options={{hash
maximum=1
caretDownIcon="caret-down"
caretUpIcon="caret-up"
icons=@link.icon
}}
class={{@link.iconCssClass}}
@onlyAvailable={{true}}
@onChange={{action (mut @link.icon)}}
/>
{{#if @link.invalidIconMessage}}
<div class="icon warning">
{{@link.invalidIconMessage}}
</div>
{{/if}}
</div>
<div class="input-group">
<Input
name="link-name"
@type="text"
@value={{@link.name}}
class={{@link.nameCssClass}}
{{on "input" (action (mut @link.name) value="target.value")}}
/>
{{#if @link.invalidNameMessage}}
<div class="name warning">
{{@link.invalidNameMessage}}
</div>
{{/if}}
</div>
<div class="input-group">
<Input
name="link-url"
@type="text"
@value={{@link.value}}
class={{@link.valueCssClass}}
{{on "input" (action (mut @link.value) value="target.value")}}
/>
{{#if @link.invalidValueMessage}}
<div class="value warning">
{{@link.invalidValueMessage}}
</div>
{{/if}}
</div>
<DButton
@icon="trash-alt"
@action={{action @deleteLink @link}}
@class="btn-flat delete-link"
@title="sidebar.sections.custom.links.delete"
/>
</div>

@ -0,0 +1,68 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
export default class SectionFormLink extends Component {
@tracked dragCssClass;
dragCount = 0;
isAboveElement(event) {
event.preventDefault();
const target = event.currentTarget;
const domRect = target.getBoundingClientRect();
return event.offsetY < domRect.height / 2;
}
@action
dragHasStarted(event) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("linkId", this.args.link.objectId);
this.dragCssClass = "dragging";
}
@action
dragOver(event) {
event.preventDefault();
if (!this.dragCssClass) {
if (this.isAboveElement(event)) {
this.dragCssClass = "drag-above";
} else {
this.dragCssClass = "drag-below";
}
}
}
@action
dragEnter() {
this.dragCount++;
}
@action
dragLeave() {
this.dragCount--;
if (
this.dragCount === 0 &&
(this.dragCssClass === "drag-above" || this.dragCssClass === "drag-below")
) {
this.dragCssClass = null;
}
}
@action
dropItem(event) {
event.stopPropagation();
this.dragCounter = 0;
this.args.reorderCallback(
parseInt(event.dataTransfer.getData("linkId"), 10),
this.args.link,
this.isAboveElement(event)
);
this.dragCssClass = null;
}
@action
dragEnd() {
this.dragCounter = 0;
this.dragCssClass = null;
}
}

@ -9,6 +9,7 @@ import { sanitize } from "discourse/lib/text";
import { tracked } from "@glimmer/tracking";
import { A } from "@ember/array";
import { SIDEBAR_SECTION, SIDEBAR_URL } from "discourse/lib/constants";
import { bind } from "discourse-common/utils/decorators";
const FULL_RELOAD_LINKS_REGEX = [
/^\/my\/[a-z_\-\/]+$/,
@ -19,17 +20,30 @@ const FULL_RELOAD_LINKS_REGEX = [
class Section {
@tracked title;
@tracked links;
@tracked secondaryLinks;
constructor({ title, links, id, publicSection }) {
constructor({
title,
links,
secondaryLinks,
id,
publicSection,
sectionType,
}) {
this.title = title;
this.public = publicSection;
this.sectionType = sectionType;
this.links = links;
this.secondaryLinks = secondaryLinks;
this.id = id;
}
get valid() {
const allLinks = this.links
.filter((link) => !link._destroy)
.concat(this.secondaryLinks?.filter((link) => !link._destroy) || []);
const validLinks =
this.links.length > 0 && this.links.every((link) => link.valid);
allLinks.length > 0 && allLinks.every((link) => link.valid);
return this.validTitle && validLinks;
}
@ -70,7 +84,7 @@ class SectionLink {
@tracked value;
@tracked _destroy;
constructor({ router, icon, name, value, id }) {
constructor({ router, icon, name, value, id, objectId, segment }) {
this.router = router;
this.icon = icon || "link";
this.name = name;
@ -78,6 +92,8 @@ class SectionLink {
this.id = id;
this.httpHost = "http://" + window.location.host;
this.httpsHost = "https://" + window.location.host;
this.objectId = objectId;
this.segment = segment;
}
get path() {
@ -165,6 +181,10 @@ class SectionLink {
);
}
get isPrimary() {
return this.segment === "primary";
}
get #blankIcon() {
return isEmpty(this.icon);
}
@ -221,6 +241,7 @@ export default Controller.extend(ModalFunctionality, {
flashText: null,
flashClass: null,
});
this.nextObjectId = 0;
this.model = this.initModel();
},
@ -233,27 +254,48 @@ export default Controller.extend(ModalFunctionality, {
return new Section({
title: this.model.title,
publicSection: this.model.public,
links: A(
this.model.links.map(
(link) =>
new SectionLink({
router: this.router,
icon: link.icon,
name: link.name,
value: link.value,
id: link.id,
})
)
),
sectionType: this.model.section_type,
links: this.model.links.reduce((acc, link) => {
if (link.segment === "primary") {
this.nextObjectId++;
acc.push(this.initLink(link));
}
return acc;
}, A()),
secondaryLinks: this.model.links.reduce((acc, link) => {
if (link.segment === "secondary") {
this.nextObjectId++;
acc.push(this.initLink(link));
}
return acc;
}, A()),
id: this.model.id,
});
} else {
return new Section({
links: A([new SectionLink({ router: this.router })]),
links: A([
new SectionLink({
router: this.router,
objectId: this.nextObjectId,
segment: "primary",
}),
]),
});
}
},
initLink(link) {
return new SectionLink({
router: this.router,
icon: link.icon,
name: link.name,
value: link.value,
id: link.id,
objectId: this.nextObjectId,
segment: link.segment,
});
},
create() {
return ajax(`/sidebar_sections`, {
type: "POST",
@ -294,15 +336,18 @@ export default Controller.extend(ModalFunctionality, {
data: JSON.stringify({
title: this.model.title,
public: this.model.public,
links: this.model.links.map((link) => {
return {
id: link.id,
icon: link.icon,
name: link.name,
value: link.path,
_destroy: link._destroy,
};
}),
links: this.model.links
.concat(this.model?.secondaryLinks || [])
.map((link) => {
return {
id: link.id,
icon: link.icon,
name: link.name,
value: link.path,
segment: link.segment,
_destroy: link._destroy,
};
}),
}),
})
.then((data) => {
@ -329,23 +374,112 @@ export default Controller.extend(ModalFunctionality, {
return this.model.links.filter((link) => !link._destroy);
},
get activeSecondaryLinks() {
return this.model.secondaryLinks?.filter((link) => !link._destroy);
},
get header() {
return this.model.id
? "sidebar.sections.custom.edit"
: "sidebar.sections.custom.add";
},
@bind
reorder(linkFromId, linkTo, above) {
if (linkFromId === linkTo.objectId) {
return;
}
let linkFrom = this.model.links.find(
(link) => link.objectId === linkFromId
);
if (!linkFrom) {
linkFrom = this.model.secondaryLinks.find(
(link) => link.objectId === linkFromId
);
}
if (linkFrom.isPrimary) {
this.model.links.removeObject(linkFrom);
} else {
this.model.secondaryLinks?.removeObject(linkFrom);
}
if (linkTo.isPrimary) {
const toPosition = this.model.links.indexOf(linkTo);
linkFrom.segment = "primary";
this.model.links.insertAt(above ? toPosition : toPosition + 1, linkFrom);
} else {
linkFrom.segment = "secondary";
const toPosition = this.model.secondaryLinks.indexOf(linkTo);
this.model.secondaryLinks.insertAt(
above ? toPosition : toPosition + 1,
linkFrom
);
}
},
get canDelete() {
return this.model.id && !this.model.sectionType;
},
@bind
deleteLink(link) {
if (link.id) {
link._destroy = "1";
} else {
if (link.isPrimary) {
this.model.links.removeObject(link);
} else {
this.model.secondaryLinks.removeObject(link);
}
}
},
actions: {
addLink() {
this.model.links.pushObject(new SectionLink({ router: this.router }));
this.nextObjectId = this.nextObjectId + 1;
this.model.links.pushObject(
new SectionLink({
router: this.router,
objectId: this.nextObjectId,
segment: "primary",
})
);
},
deleteLink(link) {
if (link.id) {
link._destroy = "1";
} else {
this.model.links.removeObject(link);
}
addSecondaryLink() {
this.nextObjectId = this.nextObjectId + 1;
this.model.secondaryLinks.pushObject(
new SectionLink({
router: this.router,
objectId: this.nextObjectId,
segment: "secondary",
})
);
},
resetToDefault() {
return this.dialog.yesNoConfirm({
message: I18n.t("sidebar.sections.custom.reset_confirm"),
didConfirm: () => {
return ajax(`/sidebar_sections/reset/${this.model.id}`, {
type: "PUT",
})
.then((data) => {
this.currentUser.sidebar_sections.shiftObject();
this.currentUser.sidebar_sections.unshiftObject(
data["sidebar_section"]
);
this.send("closeModal");
})
.catch((e) =>
this.setProperties({
flashText: sanitize(extractError(e)),
flashClass: "error",
})
);
},
});
},
save() {

@ -9,6 +9,8 @@ export default class BaseCommunitySectionLink {
router,
siteSettings,
inMoreDrawer,
overridenName,
overridenIcon,
} = {}) {
this.router = router;
this.topicTrackingState = topicTrackingState;
@ -16,6 +18,8 @@ export default class BaseCommunitySectionLink {
this.appEvents = appEvents;
this.siteSettings = siteSettings;
this.inMoreDrawer = inMoreDrawer;
this.overridenName = overridenName;
this.overridenIcon = overridenIcon;
}
/**
@ -105,10 +109,17 @@ export default class BaseCommunitySectionLink {
/**
* @returns {string} The name of the fontawesome icon to be displayed before the link. Defaults to "link".
*/
get prefixValue() {
get defaultPrefixValue() {
return "link";
}
/**
* @returns {string} The name of the fontawesome icon to be displayed before the link.
*/
get prefixValue() {
return this.overridenIcon || this.defaultPrefixValue;
}
_notImplemented() {
throw "not implemented";
}

@ -16,10 +16,13 @@ export default class AboutSectionLink extends BaseSectionLink {
}
get text() {
return I18n.t("sidebar.sections.community.links.about.content");
return I18n.t(
`sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`,
{ defaultValue: this.overridenName }
);
}
get prefixValue() {
get defaultPrefixValue() {
return "info-circle";
}
}

@ -16,14 +16,17 @@ export default class BadgesSectionLink extends BaseSectionLink {
}
get text() {
return I18n.t("sidebar.sections.community.links.badges.content");
return I18n.t(
`sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`,
{ defaultValue: this.overridenName }
);
}
get shouldDisplay() {
return this.siteSettings.enable_badges;
}
get prefixValue() {
get defaultPrefixValue() {
return "certificate";
}
}

@ -44,7 +44,10 @@ export default class EverythingSectionLink extends BaseSectionLink {
}
get text() {
return I18n.t("sidebar.sections.community.links.everything.content");
return I18n.t(
`sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`,
{ defaultValue: this.overridenName }
);
}
get currentWhen() {
@ -92,7 +95,7 @@ export default class EverythingSectionLink extends BaseSectionLink {
return "discovery.latest";
}
get prefixValue() {
get defaultPrefixValue() {
return "layer-group";
}

@ -20,10 +20,13 @@ export default class FAQSectionLink extends BaseSectionLink {
}
get text() {
return I18n.t("sidebar.sections.community.links.faq.content");
return I18n.t(
`sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`,
{ defaultValue: this.overridenName }
);
}
get prefixValue() {
get defaultPrefixValue() {
return "question-circle";
}
}

@ -16,14 +16,17 @@ export default class GroupsSectionLink extends BaseSectionLink {
}
get text() {
return I18n.t("sidebar.sections.community.links.groups.content");
return I18n.t(
`sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`,
{ defaultValue: this.overridenName }
);
}
get shouldDisplay() {
return this.siteSettings.enable_group_directory;
}
get prefixValue() {
get defaultPrefixValue() {
return "user-friends";
}
}

@ -16,7 +16,10 @@ export default class UsersSectionLink extends BaseSectionLink {
}
get text() {
return I18n.t("sidebar.sections.community.links.users.content");
return I18n.t(
`sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`,
{ defaultValue: this.overridenName }
);
}
get shouldDisplay() {
@ -26,7 +29,7 @@ export default class UsersSectionLink extends BaseSectionLink {
);
}
get prefixValue() {
get defaultPrefixValue() {
return "users";
}
}

@ -20,6 +20,7 @@ import {
customSectionLinks,
secondaryCustomSectionLinks,
} from "discourse/lib/sidebar/custom-community-section-links";
import showModal from "discourse/lib/show-modal";
const LINKS_IN_BOTH_SEGMENTS = ["/review"];
@ -111,13 +112,23 @@ export default class CommunitySection {
const sectionLinkClass = SPECIAL_LINKS_MAP[link.value];
if (sectionLinkClass) {
return this.#initializeSectionLink(sectionLinkClass, inMoreDrawer);
return this.#initializeSectionLink(
sectionLinkClass,
inMoreDrawer,
link.name,
link.scon
);
} else {
return new SectionLink(link, this, this.router);
}
}
#initializeSectionLink(sectionLinkClass, inMoreDrawer) {
#initializeSectionLink(
sectionLinkClass,
inMoreDrawer,
overridenName,
overridenIcon
) {
if (this.router.isDestroying) {
return;
}
@ -128,28 +139,48 @@ export default class CommunitySection {
router: this.router,
siteSettings: this.siteSettings,
inMoreDrawer,
overridenName,
overridenIcon,
});
}
get decoratedTitle() {
return I18n.t(
`sidebar.sections.${this.section.title.toLowerCase()}.header_link_text`
`sidebar.sections.${this.section.title.toLowerCase()}.header_link_text`,
{ defaultValue: this.section.title }
);
}
get headerActions() {
if (this.currentUser?.admin) {
return [
{
action: this.editSection,
title: I18n.t(
"sidebar.sections.community.header_action_edit_section_title"
),
},
];
}
if (this.currentUser) {
return [
{
action: this.composeTopic,
title: I18n.t("sidebar.sections.community.header_action_title"),
title: I18n.t(
"sidebar.sections.community.header_action_create_topic_title"
),
},
];
}
}
get headerActionIcon() {
return "plus";
return this.currentUser?.admin ? "pencil-alt" : "plus";
}
@action
editSection() {
showModal("sidebar-section-form", { model: this.section });
}
@action

@ -16,14 +16,17 @@ export default class AdminSectionLink extends BaseSectionLink {
}
get text() {
return I18n.t("sidebar.sections.community.links.admin.content");
return I18n.t(
`sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`,
{ defaultValue: this.overridenName }
);
}
get shouldDisplay() {
return this.currentUser?.staff;
return !!this.currentUser?.staff;
}
get prefixValue() {
get defaultPrefixValue() {
return "wrench";
}
}

@ -71,7 +71,12 @@ export default class MyPostsSectionLink extends BaseSectionLink {
if (this._hasDraft && this.currentUser?.new_new_view_enabled) {
return I18n.t("sidebar.sections.community.links.my_posts.content_drafts");
} else {
return I18n.t("sidebar.sections.community.links.my_posts.content");
return I18n.t(
`sidebar.sections.community.links.${this.overridenName
.toLowerCase()
.replace(" ", "/")}.content`,
{ defaultValue: this.overridenName }
);
}
}
@ -90,7 +95,7 @@ export default class MyPostsSectionLink extends BaseSectionLink {
return this.draftCount > 0;
}
get prefixValue() {
get defaultPrefixValue() {
if (this._hasDraft && this.currentUser?.new_new_view_enabled) {
return "pencil-alt";
}

@ -53,7 +53,10 @@ export default class ReviewSectionLink extends BaseSectionLink {
}
get text() {
return I18n.t("sidebar.sections.community.links.review.content");
return I18n.t(
`sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`,
{ defaultValue: this.overridenName }
);
}
get shouldDisplay() {
@ -70,7 +73,7 @@ export default class ReviewSectionLink extends BaseSectionLink {
}
}
get prefixValue() {
get defaultPrefixValue() {
return "flag";
}
}

@ -22,67 +22,23 @@
</div>
{{/if}}
</div>
{{#each this.activeLinks as |link|}}
<div class="row-wrapper">
<div class="input-group">
<label for="link-name">{{i18n
"sidebar.sections.custom.links.icon.label"
}}</label>
<IconPicker
@name="icon"
@value={{link.icon}}
@options={{hash maximum=1}}
class={{link.iconCssClass}}
@onlyAvailable={{true}}
@onChange={{action (mut link.icon)}}
/>
{{#if link.invalidIconMessage}}
<div class="icon warning">
{{link.invalidIconMessage}}
</div>
{{/if}}
</div>
<div class="input-group">
<label for="link-name">{{i18n
"sidebar.sections.custom.links.name.label"
}}</label>
<Input
name="link-name"
@type="text"
@value={{link.name}}
class={{link.nameCssClass}}
{{on "input" (action (mut link.name) value="target.value")}}
/>
{{#if link.invalidNameMessage}}
<div class="name warning">
{{link.invalidNameMessage}}
</div>
{{/if}}
</div>
<div class="input-group">
<label for="link-url">{{i18n
"sidebar.sections.custom.links.value.label"
}}</label>
<Input
name="link-url"
@type="text"
@value={{link.value}}
class={{link.valueCssClass}}
{{on "input" (action (mut link.value) value="target.value")}}
/>
{{#if link.invalidValueMessage}}
<div class="value warning">
{{link.invalidValueMessage}}
</div>
{{/if}}
</div>
<DButton
@icon="trash-alt"
@action={{action "deleteLink" link}}
@class="btn-flat delete-link"
@title="sidebar.sections.custom.links.delete"
/>
<div class="row-wrapper header">
<div class="input-group link-icon">
<label>{{i18n "sidebar.sections.custom.links.icon.label"}}</label>
</div>
<div class="input-group link-name">
<label>{{i18n "sidebar.sections.custom.links.name.label"}}</label>
</div>
<div class="input-group link-url">
<label>{{i18n "sidebar.sections.custom.links.value.label"}}</label>
</div>
</div>
{{#each this.activeLinks as |link|}}
<Sidebar::SectionFormLink
@link={{link}}
@deleteLink={{this.deleteLink}}
@reorderCallback={{this.reorder}}
/>
{{/each}}
<DButton
@action={{action "addLink"}}
@ -90,9 +46,40 @@
@title="sidebar.sections.custom.links.add"
@icon="plus"
@label="sidebar.sections.custom.links.add"
@ariaLabel="sidebar.sections.custom.links.add"
/>
{{#if this.currentUser.staff}}
<div class="row-wrapper">
{{#if this.model.sectionType}}
<hr />
<h3>{{i18n "sidebar.sections.custom.more_menu"}}</h3>
{{#each this.activeSecondaryLinks as |link|}}
<Sidebar::SectionFormLink
@link={{link}}
@deleteLink={{this.deleteLink}}
@reorderCallback={{this.reorder}}
/>
{{/each}}
<DButton
@action={{action "addSecondaryLink"}}
@class="btn-flat btn-text add-link"
@title="sidebar.sections.custom.links.add"
@icon="plus"
@label="sidebar.sections.custom.links.add"
@ariaLabel="sidebar.sections.custom.links.add"
/>
{{#if this.model.sectionType}}
<DButton
@action={{action "resetToDefault"}}
@class="btn-flat btn-text reset-link"
@icon="undo"
@title="sidebar.sections.custom.links.reset"
@label="sidebar.sections.custom.links.reset"
@ariaLabel="sidebar.sections.custom.links.reset"
/>
{{/if}}
{{/if}}
{{#if (and this.currentUser.staff (not this.model.sectionType))}}
<div class="row-wrapper mark-public-wrapper">
<label class="checkbox-label">
<Input
@type="checkbox"
@ -112,15 +99,17 @@
@action={{action "save"}}
@class="btn-primary"
@label="sidebar.sections.custom.save"
@ariaLabel="sidebar.sections.custom.save"
@disabled={{not this.model.valid}}
/>
{{#if this.model.id}}
{{#if this.canDelete}}
<DButton
@icon="trash-alt"
@id="delete-section"
@class="btn-danger delete"
@action={{action "delete"}}
@label="sidebar.sections.custom.delete"
@ariaLabel="sidebar.sections.custom.delete"
/>
{{/if}}
</div>

@ -25,6 +25,7 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) {
tracked_tags: ["tag1"],
watched_tags: ["tag2"],
watching_first_post_tags: ["tag3"],
admin: false,
});
needs.settings({

@ -75,7 +75,7 @@ export default {
},
{
id: 331,
name: "Info",
name: "About",
value: "/about",
icon: "info-circle",
external: false,

@ -723,7 +723,7 @@ export default {
},
{
id: 331,
name: "Info",
name: "About",
value: "/about",
icon: "info-circle",
external: false,

@ -16,6 +16,10 @@
.sidebar-section-link-prefix.icon {
cursor: move;
}
.sidebar-section[data-section-name="community"]
.sidebar-section-link-prefix.icon {
cursor: pointer;
}
a {
-webkit-touch-callout: none !important;

@ -106,8 +106,21 @@
}
}
}
.sidebar-section-form-modal {
.draggable {
cursor: move;
align-self: center;
margin-left: auto;
margin-right: auto;
-webkit-user-drag: element;
-khtml-user-drag: element;
-moz-user-drag: element;
-o-user-drag: element;
user-drag: element;
}
.dragging {
opacity: 0.4;
}
.modal-inner-container {
width: var(--modal-max-width);
}
@ -122,17 +135,52 @@
}
.row-wrapper {
display: grid;
grid-template-columns: auto auto auto 2em;
grid-template-columns: 25px 60px auto auto 2em;
gap: 1em;
margin-top: 1em;
padding: 0.5em 1px;
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
cursor: default;
&.header {
padding-bottom: 0;
padding-top: 1em;
label {
margin-bottom: 0;
}
.link-url {
margin-left: -1em;
}
}
&.drag-above {
border-top: 1px dotted #666;
margin-top: -1px;
}
&.drag-below {
border-bottom: 1px dotted #666;
padding-bottom: calc(0.5em - 1px);
}
.link-icon {
grid-column: 1 / span 2;
padding-left: calc(25px + 1em);
}
&.mark-public-wrapper {
label {
grid-column: 1 / -1;
}
}
}
.delete-link {
height: 1em;
align-self: end;
margin-bottom: 0.75em;
align-self: center;
margin-right: 1em;
}
.btn-flat.add-link {
.btn-flat.add-link,
.btn-flat.reset-link {
margin-top: 1em;
margin-left: -0.65em;
&:active,
@ -148,6 +196,9 @@
color: var(--tertiary-hover);
}
}
.btn-flat.reset-link {
float: right;
}
.modal-footer {
display: flex;
justify-content: space-between;
@ -156,4 +207,13 @@
margin-right: 0;
}
}
.select-kit.multi-select .multi-select-header .formatted-selection {
display: none;
}
.modal-inner-container .select-kit {
width: 60px;
}
.select-kit.is-expanded .select-kit-body {
width: 220px !important;
}
}

@ -59,7 +59,7 @@ class SidebarSectionsController < ApplicationController
Site.clear_anon_cache!
end
render_serialized(sidebar_section, SidebarSectionSerializer)
render_serialized(sidebar_section.reload, SidebarSectionSerializer)
rescue ActiveRecord::RecordInvalid => e
render_json_error(e.record.errors.full_messages.first)
rescue Discourse::InvalidAccess

@ -4413,10 +4413,13 @@ en:
save: "Save"
delete: "Delete"
delete_confirm: "Are you sure you want to delete this section?"
reset_confirm: "Are you sure you want to reset this section to default?"
public: "Make this section public and visible to everyone"
more_menu: "More menu"
links:
add: "Add another link"
delete: "Delete link"
reset: "Reset to default"
icon:
label: "Icon"
validation:
@ -4473,7 +4476,8 @@ en:
configure_defaults: "Configure defaults"
community:
header_link_text: "Community"
header_action_title: "Create a topic"
header_action_create_topic_title: "Create a topic"
header_action_edit_section_title: "Edit Community section"
links:
about:
content: "About"

@ -114,25 +114,17 @@ describe "Custom sidebar sections", type: :system, js: true do
sign_in user
visit("/latest")
within("[data-section-name='my-section'] .sidebar-section-link-wrapper:nth-child(1)") do
expect(sidebar).to have_section_link("Sidebar Tags")
end
within("[data-section-name='my-section'] .sidebar-section-link-wrapper:nth-child(2)") do
expect(sidebar).to have_section_link("Sidebar Categories")
end
expect(sidebar.primary_section_links("my-section")).to eq(
["Sidebar Tags", "Sidebar Categories"],
)
tags_link = find(".sidebar-section-link[data-link-name='Sidebar Tags']")
categories_link = find(".sidebar-section-link[data-link-name='Sidebar Categories']")
tags_link.drag_to(categories_link, html5: true, delay: 0.4)
within("[data-section-name='my-section'] .sidebar-section-link-wrapper:nth-child(1)") do
expect(sidebar).to have_section_link("Sidebar Categories")
end
within("[data-section-name='my-section'] .sidebar-section-link-wrapper:nth-child(2)") do
expect(sidebar).to have_section_link("Sidebar Tags")
end
expect(sidebar.primary_section_links("my-section")).to eq(
["Sidebar Categories", "Sidebar Tags"],
)
end
it "does not allow the user to edit public section" do
@ -201,6 +193,29 @@ describe "Custom sidebar sections", type: :system, js: true do
expect(sidebar).to have_no_section("Edited public section")
end
it "allows admin to edit community section and reset to default" do
sign_in admin
visit("/latest")
sidebar.edit_custom_section("Community")
section_modal.fill_name("Edited community section")
section_modal.everything_link.drag_to(section_modal.review_link, delay: 0.4)
section_modal.save
expect(sidebar).to have_section("Edited community section")
expect(sidebar.primary_section_links("edited-community-section")).to eq(
["My Posts", "Everything", "Admin", "More"],
)
sidebar.edit_custom_section("Edited community section")
section_modal.reset
expect(sidebar).to have_section("Community")
expect(sidebar.primary_section_links("community")).to eq(
["Everything", "My Posts", "Admin", "More"],
)
end
it "shows anonymous public sections" do
sidebar_section = Fabricate(:sidebar_section, title: "Public section", public: true)
sidebar_url_1 = Fabricate(:sidebar_url, name: "Sidebar Tags", value: "/tags")

@ -63,6 +63,10 @@ module PageObjects
find(SIDEBAR_WRAPPER_SELECTOR).has_no_button?(name)
end
def primary_section_links(slug)
all("[data-section-name='#{slug}'] .sidebar-section-link-wrapper").map(&:text)
end
private
def section_link_present?(name, href: nil, active: false, present:)

@ -25,7 +25,7 @@ module PageObjects
def click_community_header_button
page.click_button(
I18n.t("js.sidebar.sections.community.header_action_title"),
I18n.t("js.sidebar.sections.community.header_action_create_topic_title"),
class: "sidebar-section-header-button",
)
end

@ -28,6 +28,11 @@ module PageObjects
find(".dialog-container .btn-primary").click
end
def reset
find(".reset-link").click
find(".dialog-footer .btn-primary").click
end
def save
find("#save-section").click
end
@ -39,9 +44,18 @@ module PageObjects
def has_disabled_save?
find_button("Save", disabled: true)
end
def has_enabled_save?
find_button("Save", disabled: false)
end
def everything_link
find(".draggable[data-link-name='Everything']")
end
def review_link
find(".draggable[data-link-name='Review']")
end
end
end
end