UX: First pass at edit categories navigation modal for sidebar (#21963)

What this change?

We are currently not fully satisfied with the current way to edit the
categories and tags that appears in the sidebar where the user is
redirected to the tracking preferences tab in the user's profile causing
the user to lose context of the current page. In addition, the dropdown
to select categories or tags limits the amount of information we can
display.

Since editing or adding a custom categories section is already using a
modal, we have decided to switch editing the categories and tags that
appear in the sidebar to use a modal as well.

This commit ships a first pass of the edit categories modal such that we
can keep the commit small and reviewable. The incomplete nature of the
feature is also reflected in the fact that the feature is hidden behind
a new `new_edit_sidebar_categories_tags_interface_groups` site setting.
This commit is contained in:
Alan Guo Xiang Tan 2023-06-07 13:09:30 +09:00 committed by GitHub
parent 213d9dbe41
commit fc296b9a81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 349 additions and 3 deletions

View File

@ -0,0 +1,81 @@
<DModalBody
@title="sidebar.categories_form.title"
@class="sidebar-categories-form-modal"
>
<form class="sidebar-categories-form">
{{#each this.siteCategories as |category|}}
<div
class="sidebar-categories-form__row"
style={{html-safe (border-color category.color "left")}}
>
<div
class="sidebar-categories-form__category-row"
data-category-id={{category.id}}
>
<label
class="sidebar-categories-form__category-label"
for={{concat "sidebar-categories-form__input--" category.id}}
>
<div
class="sidebar-categories-form__category-badge sidebar-categories-form__category-badge--parent-category"
>
{{category-badge category}}
</div>
<div class="sidebar-categories-form__category-description">
{{dir-span category.description_excerpt htmlSafe="true"}}
</div>
</label>
<Input
id={{concat "sidebar-categories-form__input--" category.id}}
class="sidebar-categories-form__input"
@type="checkbox"
@checked={{includes this.selectedSidebarCategoryIds category.id}}
{{on "click" (action "toggleCategory" category.id)}}
/>
</div>
{{#if (gt category.subcategories.length 0)}}
{{#each category.subcategories as |subcategory|}}
<div
class="sidebar-categories-form__category-row"
data-category-id={{subcategory.id}}
>
<label
class="sidebar-categories-form__category-label"
for={{concat "sidebar-categories-form__input--" subcategory.id}}
>
<div
class="sidebar-categories-form__category-badge sidebar-categories-form__category-badge--subcategory"
>
{{category-badge subcategory}}
</div>
</label>
<Input
id={{concat "sidebar-categories-form__input--" subcategory.id}}
class="sidebar-categories-form__input"
@type="checkbox"
@checked={{includes
this.selectedSidebarCategoryIds
subcategory.id
}}
{{on "click" (action "toggleCategory" subcategory.id)}}
/>
</div>
{{/each}}
{{/if}}
</div>
{{/each}}
</form>
</DModalBody>
<div class="modal-footer">
<DButton
@class="btn-primary sidebar-categories-form__save-button"
@label="sidebar.categories_form.save"
@disabled={{this.saving}}
@action={{this.save}}
/>
</div>

View File

@ -0,0 +1,52 @@
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default class extends Component {
@service site;
@service currentUser;
@tracked selectedSidebarCategoryIds = [
...this.currentUser.sidebar_category_ids,
];
@tracked siteCategories = this.site.categoriesList.filter((category) => {
return !category.parent_category_id && !category.isUncategorizedCategory;
});
@action
toggleCategory(categoryId) {
if (this.selectedSidebarCategoryIds.includes(categoryId)) {
this.selectedSidebarCategoryIds.removeObject(categoryId);
} else {
this.selectedSidebarCategoryIds.addObject(categoryId);
}
}
@action
save() {
this.saving = true;
const initialSidebarCategoryIds = this.currentUser.sidebar_category_ids;
this.currentUser.set(
"sidebar_category_ids",
this.selectedSidebarCategoryIds
);
this.currentUser
.save(["sidebar_category_ids"])
.then(() => {
this.args.closeModal();
})
.catch((error) => {
this.currentUser.set("sidebar_category_ids", initialSidebarCategoryIds);
popupAjaxError(error);
})
.finally(() => {
this.saving = false;
});
}
}

View File

@ -33,6 +33,20 @@
data-category-id={{sectionLink.category.id}} data-category-id={{sectionLink.category.id}}
/> />
{{/each}} {{/each}}
{{else if this.currentUser.new_edit_sidebar_categories_tags_interface_groups_enabled}}
<Sidebar::SectionLink
@linkName="configure-categories"
@prefixType="icon"
@prefixValue="pencil-alt"
@model={{this.currentUser}}
@content={{i18n
"sidebar.sections.categories.links.add_categories.content"
}}
@title={{i18n
"sidebar.sections.categories.links.add_categories.title"
}}
{{on "click" this.editTracked}}
/>
{{else}} {{else}}
<Sidebar::SectionLink <Sidebar::SectionLink
@linkName="configure-categories" @linkName="configure-categories"

View File

@ -5,6 +5,7 @@ import { cached } from "@glimmer/tracking";
import { debounce } from "discourse-common/utils/decorators"; import { debounce } from "discourse-common/utils/decorators";
import Category from "discourse/models/category"; import Category from "discourse/models/category";
import SidebarCommonCategoriesSection from "discourse/components/sidebar/common/categories-section"; import SidebarCommonCategoriesSection from "discourse/components/sidebar/common/categories-section";
import showModal from "discourse/lib/show-modal";
export const REFRESH_COUNTS_APP_EVENT_NAME = export const REFRESH_COUNTS_APP_EVENT_NAME =
"sidebar:refresh-categories-section-counts"; "sidebar:refresh-categories-section-counts";
@ -74,6 +75,12 @@ export default class SidebarUserCategoriesSection extends SidebarCommonCategorie
@action @action
editTracked() { editTracked() {
if (
this.currentUser.new_edit_sidebar_categories_tags_interface_groups_enabled
) {
showModal("sidebar-categories-form");
} else {
this.router.transitionTo("preferences.sidebar", this.currentUser); this.router.transitionTo("preferences.sidebar", this.currentUser);
} }
}
} }

View File

@ -0,0 +1,6 @@
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default class SidebarCategoriesForm extends Controller.extend(
ModalFunctionality
) {}

View File

@ -1,3 +1,14 @@
import { htmlHelper } from "discourse-common/lib/helpers"; import { htmlHelper } from "discourse-common/lib/helpers";
export default htmlHelper((color) => `border-color: #${color}; `); const validDirections = ["top", "right", "bottom", "left"];
export default htmlHelper((color, direction) => {
const borderColor = `#${color}`;
const borderProperty =
direction && validDirections.includes(direction)
? `border-${direction}-color`
: "border-color";
return `${borderProperty}: ${borderColor} `;
});

View File

@ -0,0 +1 @@
<Sidebar::CategoriesFormModal @closeModal={{(action "closeModal")}} />

View File

@ -27,6 +27,7 @@
@import "relative-time-picker"; @import "relative-time-picker";
@import "share-and-invite-modal"; @import "share-and-invite-modal";
@import "download-calendar"; @import "download-calendar";
@import "sidebar-categories-form";
@import "svg"; @import "svg";
@import "tap-tile"; @import "tap-tile";
@import "time-input"; @import "time-input";

View File

@ -0,0 +1,58 @@
.sidebar-categories-form {
.sidebar-categories-form__row {
display: flex;
flex-direction: column;
border: 1px solid var(--primary-low);
border-left: 4px solid;
margin-bottom: 1em;
padding: 0.75em;
}
.sidebar-categories-form__input {
margin-left: auto;
margin-right: 0;
align-self: baseline;
}
.sidebar-categories-form__category-row {
display: flex;
flex-direction: row;
margin-right: 1em;
padding: 0.5em 0;
}
.sidebar-categories-form__category-row:not(:first-child) {
border-top: 1px solid var(--primary-low);
.sidebar-categories-form__input {
align-self: center;
}
}
.sidebar-categories-form__category-row:nth-child(2) {
padding-left: 1em;
}
.sidebar-categories-form__category-row:nth-child(n + 3) {
margin-left: 1em;
}
.sidebar-categories-form__category-label {
display: flex;
flex-direction: column;
margin-right: 0.5em;
flex-grow: 1;
}
.sidebar-categories-form__category-badge {
.category-name {
color: var(--primary);
font-size: var(--font-up-1);
}
}
.sidebar-categories-form__category-description {
color: var(--primary-high);
font-size: var(--font-down-1);
}
}

View File

@ -1827,6 +1827,10 @@ class User < ActiveRecord::Base
in_any_groups?(SiteSetting.experimental_new_new_view_groups_map) in_any_groups?(SiteSetting.experimental_new_new_view_groups_map)
end end
def new_edit_sidebar_categories_tags_interface_groups_enabled?
in_any_groups?(SiteSetting.new_edit_sidebar_categories_tags_interface_groups_map)
end
protected protected
def badge_grant def badge_grant

View File

@ -68,7 +68,8 @@ class CurrentUserSerializer < BasicUserSerializer
:sidebar_category_ids, :sidebar_category_ids,
:sidebar_list_destination, :sidebar_list_destination,
:sidebar_sections, :sidebar_sections,
:new_new_view_enabled? :new_new_view_enabled?,
:new_edit_sidebar_categories_tags_interface_groups_enabled?
delegate :user_stat, to: :object, private: true delegate :user_stat, to: :object, private: true
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat

View File

@ -4421,6 +4421,12 @@ en:
more: "More" more: "More"
all_categories: "All categories" all_categories: "All categories"
all_tags: "All tags" all_tags: "All tags"
categories_form:
save: "Save"
title: "Edit categories navigation"
filter_input:
placeholder: "Filter categories"
sections: sections:
custom: custom:
add: "Add custom section" add: "Add custom section"

View File

@ -2112,6 +2112,12 @@ developer:
experimental_topics_filter: experimental_topics_filter:
client: true client: true
default: false default: false
new_edit_sidebar_categories_tags_interface_groups:
type: group_list
list_type: compact
default: ""
allow_any: false
hidden: true
navigation: navigation:
navigation_menu: navigation_menu:

View File

@ -3,6 +3,7 @@
Fabricator(:category) do Fabricator(:category) do
name { sequence(:name) { |n| "Amazing Category #{n}" } } name { sequence(:name) { |n| "Amazing Category #{n}" } }
skip_category_definition true skip_category_definition true
color { SecureRandom.hex(3) }
user user
end end

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
RSpec.describe "Editing sidebar categories navigation", type: :system do
fab!(:user) { Fabricate(:user) }
fab!(:group) { Fabricate(:group).tap { |g| g.add(user) } }
fab!(:category) { Fabricate(:category) }
fab!(:category_subcategory) { Fabricate(:category, parent_category_id: category.id) }
fab!(:category_subcategory2) { Fabricate(:category, parent_category_id: category.id) }
fab!(:category2) { Fabricate(:category) }
fab!(:category2_subcategory) { Fabricate(:category, parent_category_id: category2.id) }
let(:sidebar) { PageObjects::Components::Sidebar.new }
before do
SiteSetting.new_edit_sidebar_categories_tags_interface_groups = group.name
SiteSetting.default_sidebar_categories = "#{category.id}|#{category2.id}"
sign_in(user)
end
it "allows a user to edit the sidebar categories navigation" do
visit "/latest"
expect(sidebar).to have_categories_section
modal = sidebar.click_edit_categories_button
expect(modal).to have_right_title(I18n.t("js.sidebar.categories_form.title"))
modal
.toggle_category_checkbox(category)
.toggle_category_checkbox(category_subcategory2)
.toggle_category_checkbox(category2)
.save
expect(modal).to be_closed
expect(sidebar).to have_section_link(category.name)
expect(sidebar).to have_section_link(category_subcategory2.name)
expect(sidebar).to have_section_link(category2.name)
visit "/latest"
expect(sidebar).to have_categories_section
expect(sidebar).to have_section_link(category.name)
expect(sidebar).to have_section_link(category_subcategory2.name)
expect(sidebar).to have_section_link(category2.name)
modal = sidebar.click_edit_categories_button
modal.toggle_category_checkbox(category_subcategory2).toggle_category_checkbox(category2).save
expect(modal).to be_closed
expect(sidebar).to have_section_link(category.name)
expect(sidebar).to have_no_section_link(category_subcategory2.name)
expect(sidebar).to have_no_section_link(category2.name)
end
end

View File

@ -23,6 +23,14 @@ module PageObjects
page.has_no_button?(add_section_button_text) page.has_no_button?(add_section_button_text)
end end
def click_edit_categories_button
within(".sidebar-section[data-section-name='categories']") do
click_button(class: "sidebar-section-header-button", visible: false)
end
PageObjects::Modals::SidebarEditCategories.new
end
def edit_custom_section(name) def edit_custom_section(name)
find(".sidebar-section[data-section-name='#{name.parameterize}']").hover find(".sidebar-section[data-section-name='#{name.parameterize}']").hover
@ -59,6 +67,10 @@ module PageObjects
find(SIDEBAR_WRAPPER_SELECTOR).has_button?(name) find(SIDEBAR_WRAPPER_SELECTOR).has_button?(name)
end end
def has_categories_section?
has_section?("Categories")
end
def has_no_section?(name) def has_no_section?(name)
find(SIDEBAR_WRAPPER_SELECTOR).has_no_button?(name) find(SIDEBAR_WRAPPER_SELECTOR).has_no_button?(name)
end end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
module PageObjects
module Modals
class SidebarEditCategories < PageObjects::Modals::Base
MODAL_SELECTOR = ".sidebar-categories-form-modal"
def closed?
has_no_css?(MODAL_SELECTOR)
end
def has_right_title?(title)
has_css?("#{MODAL_SELECTOR} #discourse-modal-title", text: title)
end
def toggle_category_checkbox(category)
find(
"#{MODAL_SELECTOR} .sidebar-categories-form__category-row[data-category-id='#{category.id}'] .sidebar-categories-form__input",
).click
self
end
def save
find("#{MODAL_SELECTOR} .sidebar-categories-form__save-button").click
end
end
end
end