DEV: First pass at side topics section (#16697)

* Implements everything, tracked and bookmarked links
* Implements unread/new count for everything link
This commit is contained in:
Alan Guo Xiang Tan 2022-05-11 13:43:24 +08:00 committed by GitHub
parent 19677ce3f6
commit 9b420eb6e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 725 additions and 2 deletions

View File

@ -0,0 +1,51 @@
import Ember from "ember";
export default class SidebarSectionLinkTo extends Ember.LinkComponent {
// Overriding the private function here because the behavior of the component when used with the `current-when`
// attribute does not seem to follow what was mentioned in the docs: "A link will be active if current-when is true or
// the current route is the route this link would transition to". When the `current-when` attribute is used, the
// `route` and `query` attributes are ignored which is not what we want. In addition, we're stuck on Ember 3.15 at
// the moment and are awaiting the upgrade to the latest supported Ember version before I can determine if this is a
// bug and report it as such.
_isActive(routerState) {
if (this.loading) {
return false;
}
let currentWhen = this["current-when"];
if (typeof currentWhen === "boolean") {
return currentWhen;
}
let isCurrentWhenSpecified = Boolean(currentWhen);
if (isCurrentWhenSpecified) {
currentWhen = currentWhen.split(" ");
} else {
currentWhen = [this._route];
}
let { _models: models, _query: query, _routing: routing } = this;
for (let i = 0; i < currentWhen.length; i++) {
if (
routing.isActiveForRoute(
models,
query,
currentWhen[i],
routerState,
// **custom code override start**
// we always want query params to be considered
false
// isCurrentWhenSpecified
// **custom code override end**
)
) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,17 @@
import GlimmerComponent from "discourse/components/glimmer";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
export default class SidebarSection extends GlimmerComponent {
@tracked displaySection = true;
@action
toggleSectionDisplay() {
this.displaySection = !this.displaySection;
}
get headerCaretIcon() {
return this.displaySection ? "angle-down" : "angle-up";
}
}

View File

@ -0,0 +1,90 @@
import I18n from "I18n";
import GlimmerComponent from "discourse/components/glimmer";
import Composer from "discourse/models/composer";
import { getOwner } from "discourse-common/lib/get-owner";
import PermissionType from "discourse/models/permission-type";
import discourseDebounce from "discourse-common/lib/debounce";
import { action } from "@ember/object";
import { next } from "@ember/runloop";
import { tracked } from "@glimmer/tracking";
export default class SidebarTopicsSection extends GlimmerComponent {
@tracked totalUnread = 0;
@tracked totalNew = 0;
constructor(owner, args) {
super(owner, args);
this._refreshSectionCounts();
this.topicTrackingState.onStateChange(
this._topicTrackingStateUpdated.bind(this)
);
}
_topicTrackingStateUpdated() {
// refreshing section counts by looping through the states in topicTrackingState is an expensive operation so
// we debounce this.
discourseDebounce(this, this._refreshSectionCounts, 100);
}
_refreshSectionCounts() {
let totalUnread = 0;
let totalNew = 0;
this.topicTrackingState.forEachTracked((topic, isNew, isUnread) => {
if (isNew) {
totalNew += 1;
} else if (isUnread) {
totalUnread += 1;
}
});
this.totalUnread = totalUnread;
this.totalNew = totalNew;
}
get everythingSectionLinkBadgeText() {
if (this.totalUnread > 0) {
return I18n.t("sidebar.sections.links.badge.unread_count", {
count: this.totalUnread,
});
} else if (this.totalNew > 0) {
return I18n.t("sidebar.sections.links.badge.new_count", {
count: this.totalNew,
});
} else {
return;
}
}
get everythingSectionLinkRoute() {
if (this.totalUnread > 0) {
return "discovery.unread";
} else if (this.totalNew > 0) {
return "discovery.new";
} else {
return "discovery.latest";
}
}
@action
composeTopic() {
const composerArgs = {
action: Composer.CREATE_TOPIC,
draftKey: Composer.NEW_TOPIC_KEY,
};
const controller = getOwner(this).lookup("controller:navigation/category");
const category = controller.category;
if (category && category.permission === PermissionType.FULL) {
composerArgs.categoryId = category.id;
}
next(() => {
getOwner(this).lookup("controller:composer").open(composerArgs);
});
}
}

View File

@ -22,7 +22,7 @@ const controllerOpts = {
queryParams: Object.keys(queryParams),
};
// Default to `null`
// Default to `undefined`
controllerOpts.queryParams.forEach((p) => {
controllerOpts[p] = queryParams[p].default;
});

View File

@ -1,6 +1,7 @@
{{#if @shouldDisplay}}
<div class="sidebar-wrapper">
<div class="sidebar-container">
<Sidebar::TopicsSection />
</div>
</div>
{{/if}}

View File

@ -0,0 +1,18 @@
<div class="sidebar-section-link-wrapper">
<Sidebar::SectionLinkTo
@class={{concat "sidebar-section-link sidebar-section-link-" @linkName}}
@route={{@route}}
@query={{@query}}
@models={{if @model (array @model) (if @models @models (array))}}
@current-when={{@current-when}}
@title={{i18n (concat "sidebar.sections.links." @linkName ".title")}}
>
{{i18n (concat "sidebar.sections.links." @linkName ".content")}}
{{#if @badgeText}}
<span class="sidebar-section-link-content-badge">
{{@badgeText}}
</span>
{{/if}}
</Sidebar::SectionLinkTo>
</div>

View File

@ -0,0 +1,23 @@
<div class={{concat "sidebar-section-wrapper sidebar-section-" @sectionName}}>
<div class="sidebar-section-header">
<button type="button" class="sidebar-section-header-caret" title="toggle section" {{on "click" this.toggleSectionDisplay}}>
{{d-icon this.headerCaretIcon}}
</button>
<LinkTo @route={{@headerRoute}} @query={{@headerQuery}} @class="sidebar-section-header-link">
{{@headerTitle}}
</LinkTo>
{{#if @headerAction}}
<button type="button" class="sidebar-section-header-button" {{on "click" @headerAction}}>
{{d-icon @headerActionIcon}}
</button>
{{/if}}
</div>
{{#if this.displaySection}}
<div class="sidebar-section-content">
{{yield}}
</div>
{{/if}}
</div>

View File

@ -0,0 +1,18 @@
<Sidebar::Section
@sectionName="topics"
@headerRoute="discovery.latest"
@headerQuery={{hash f=undefined}}
@headerTitle="topics"
@headerActionIcon="plus"
@headerAction={{this.composeTopic}}>
<Sidebar::SectionLink
@linkName="everything"
@route={{this.everythingSectionLinkRoute}}
@query={{hash f=undefined}}
@current-when={{"discovery.latest discovery.new discovery.unread discovery.top"}}
@badgeText={{this.everythingSectionLinkBadgeText}} />
<Sidebar::SectionLink @linkName="tracked" @route="discovery.latest" @query={{hash f="tracked"}} />
<Sidebar::SectionLink @linkName="bookmarked" @route="userActivity.bookmarks" @model={{this.currentUser}} />
</Sidebar::Section>

View File

@ -0,0 +1,401 @@
import { click, currentURL, settled, visit } from "@ember/test-helpers";
import {
acceptance,
conditionalTest,
exists,
loggedInUser,
publishToMessageBus,
query,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import { isLegacyEmber } from "discourse-common/config/environment";
import topicFixtures from "discourse/tests/fixtures/discovery-fixtures";
import { cloneJSON } from "discourse-common/lib/object";
acceptance("Sidebar - Topics Section", function (needs) {
needs.user({ experimental_sidebar_enabled: true });
needs.pretender((server, helper) => {
server.get("/new.json", () => {
return helper.response(cloneJSON(topicFixtures["/latest.json"]));
});
server.get("/unread.json", () => {
return helper.response(cloneJSON(topicFixtures["/latest.json"]));
});
server.get("/top.json", () => {
return helper.response(cloneJSON(topicFixtures["/latest.json"]));
});
});
conditionalTest(
"clicking on section header button",
!isLegacyEmber(),
async function (assert) {
await visit("/");
await click(".sidebar-section-topics .sidebar-section-header-button");
assert.ok(exists("#reply-control"), "it opens the composer");
}
);
conditionalTest(
"clicking on section header button while viewing a category",
!isLegacyEmber(),
async function (assert) {
await visit("/c/bug");
await click(".sidebar-section-topics .sidebar-section-header-button");
assert.ok(exists("#reply-control"), "it opens the composer");
assert.strictEqual(
query(".category-input .selected-name .category-name").textContent,
"bug",
"the current category is prefilled in the composer input"
);
}
);
conditionalTest(
"clicking on section caret button",
!isLegacyEmber(),
async function (assert) {
await visit("/");
assert.ok(exists(".sidebar-section-content"), "shows content section");
await click(".sidebar-section-topics .sidebar-section-header-caret");
assert.ok(!exists(".sidebar-section-content"), "hides content section");
await click(".sidebar-section-topics .sidebar-section-header-caret");
assert.ok(exists(".sidebar-section-content"), "shows content section");
}
);
conditionalTest(
"clicking on section header link",
!isLegacyEmber(),
async function (assert) {
await visit("/t/280");
await click(".sidebar-section-topics .sidebar-section-header-link");
assert.strictEqual(
currentURL(),
"/latest",
"it should transistion to the homepage"
);
assert.strictEqual(
queryAll(".sidebar-section-topics .sidebar-section-link.active").length,
1,
"only one link is marked as active"
);
assert.ok(
exists(
".sidebar-section-topics .sidebar-section-link-everything.active"
),
"the everything link is marked as active"
);
}
);
conditionalTest(
"clicking on everything link",
!isLegacyEmber(),
async function (assert) {
await visit("/t/280");
await click(".sidebar-section-topics .sidebar-section-link-everything");
assert.strictEqual(
currentURL(),
"/latest",
"it should transistion to the latest page"
);
assert.strictEqual(
queryAll(".sidebar-section-topics .sidebar-section-link.active").length,
1,
"only one link is marked as active"
);
assert.ok(
exists(
".sidebar-section-topics .sidebar-section-link-everything.active"
),
"the everything link is marked as active"
);
}
);
conditionalTest(
"clicking on tracked link",
!isLegacyEmber(),
async function (assert) {
await visit("/t/280");
await click(".sidebar-section-topics .sidebar-section-link-tracked");
assert.strictEqual(
currentURL(),
"/latest?f=tracked",
"it should transistion to the tracked url"
);
assert.strictEqual(
queryAll(".sidebar-section-topics .sidebar-section-link.active").length,
1,
"only one link is marked as active"
);
assert.ok(
exists(".sidebar-section-topics .sidebar-section-link-tracked.active"),
"the tracked link is marked as active"
);
}
);
conditionalTest(
"clicking on bookmarked link",
!isLegacyEmber(),
async function (assert) {
await visit("/t/280");
await click(".sidebar-section-topics .sidebar-section-link-bookmarked");
assert.strictEqual(
currentURL(),
`/u/${loggedInUser().username}/activity/bookmarks`,
"it should transistion to the bookmarked url"
);
assert.strictEqual(
queryAll(".sidebar-section-topics .sidebar-section-link.active").length,
1,
"only one link is marked as active"
);
assert.ok(
exists(
".sidebar-section-topics .sidebar-section-link-bookmarked.active"
),
"the bookmarked link is marked as active"
);
}
);
conditionalTest(
"visiting top route",
!isLegacyEmber(),
async function (assert) {
await visit("/top");
assert.strictEqual(
queryAll(".sidebar-section-topics .sidebar-section-link.active").length,
1,
"only one link is marked as active"
);
assert.ok(
exists(
".sidebar-section-topics .sidebar-section-link-everything.active"
),
"the everything link is marked as active"
);
}
);
conditionalTest(
"visiting unread route",
!isLegacyEmber(),
async function (assert) {
await visit("/unread");
assert.strictEqual(
queryAll(".sidebar-section-topics .sidebar-section-link.active").length,
1,
"only one link is marked as active"
);
assert.ok(
exists(
".sidebar-section-topics .sidebar-section-link-everything.active"
),
"the everything link is marked as active"
);
}
);
conditionalTest(
"visiting new route",
!isLegacyEmber(),
async function (assert) {
await visit("/new");
assert.strictEqual(
queryAll(".sidebar-section-topics .sidebar-section-link.active").length,
1,
"only one link is marked as active"
);
assert.ok(
exists(
".sidebar-section-topics .sidebar-section-link-everything.active"
),
"the everything link is marked as active"
);
}
);
conditionalTest(
"new and unread count for everything link",
!isLegacyEmber(),
async function (assert) {
this.container.lookup("topic-tracking-state:main").loadStates([
{
topic_id: 1,
highest_post_number: 1,
last_read_post_number: null,
created_at: "2022-05-11T03:09:31.959Z",
category_id: 1,
notification_level: null,
created_in_new_period: true,
unread_not_too_old: true,
treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z",
},
{
topic_id: 2,
highest_post_number: 12,
last_read_post_number: 11,
created_at: "2020-02-09T09:40:02.672Z",
category_id: 2,
notification_level: 2,
created_in_new_period: false,
unread_not_too_old: true,
treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z",
},
{
topic_id: 3,
highest_post_number: 15,
last_read_post_number: 14,
created_at: "2021-06-14T12:41:02.477Z",
category_id: 3,
notification_level: 2,
created_in_new_period: false,
unread_not_too_old: true,
treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z",
},
{
topic_id: 4,
highest_post_number: 17,
last_read_post_number: 16,
created_at: "2020-10-31T03:41:42.257Z",
category_id: 4,
notification_level: 2,
created_in_new_period: false,
unread_not_too_old: true,
treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z",
},
]);
await visit("/");
assert.strictEqual(
query(
".sidebar-section-link-everything .sidebar-section-link-content-badge"
).textContent.trim(),
"3 unread",
"it displays the right unread count"
);
assert.ok(
query(".sidebar-section-link-everything").href.endsWith("/unread"),
"is links to unread filter"
);
// simulate reading topic 2
publishToMessageBus("/unread", {
topic_id: 2,
message_type: "read",
payload: {
last_read_post_number: 12,
highest_post_number: 12,
notification_level: 2,
},
});
await settled();
assert.strictEqual(
query(
".sidebar-section-link-everything .sidebar-section-link-content-badge"
).textContent.trim(),
"2 unread",
"it updates the unread count"
);
// simulate reading topic 3
publishToMessageBus("/unread", {
topic_id: 3,
message_type: "read",
payload: {
last_read_post_number: 15,
highest_post_number: 15,
notification_level: 2,
},
});
// simulate reading topic 4
publishToMessageBus("/unread", {
topic_id: 4,
message_type: "read",
payload: {
last_read_post_number: 17,
highest_post_number: 17,
notification_level: 2,
},
});
await settled();
assert.strictEqual(
query(
".sidebar-section-link-everything .sidebar-section-link-content-badge"
).textContent.trim(),
"1 new",
"it displays the new count once there are no unread topics"
);
assert.ok(
query(".sidebar-section-link-everything").href.endsWith("/new"),
"is links to new filter"
);
publishToMessageBus("/unread", {
topic_id: 1,
message_type: "read",
payload: {
last_read_post_number: 1,
highest_post_number: 1,
notification_level: 2,
},
});
await settled();
assert.ok(
!exists(
".sidebar-section-link-everything .sidebar-section-link-content-badge"
),
"it removes new count once there are no new topics"
);
assert.ok(
query(".sidebar-section-link-everything").href.endsWith("/latest"),
"is links to latest filter"
);
}
);
});

View File

@ -32,11 +32,98 @@
box-sizing: border-box;
height: 100%;
width: 240px;
padding: 1em;
padding: 1em 0.5em 1em 0;
}
.sidebar-toggle {
display: flex;
justify-content: flex-end;
}
.sidebar-section-header {
display: flex;
text-transform: uppercase;
font-size: 1em;
font-weight: bold;
align-items: center;
}
.sidebar-section-header-link {
&:visited {
color: var(--primary);
}
&:hover {
background: var(--primary-low);
}
flex: 1 1 auto;
color: var(--primary);
font-size: var(--font-down-1);
padding: 0.25em 0.5em;
}
.sidebar-section-header-button {
background: none;
border: none;
padding: 0.25em 0.5em;
.d-icon {
font-size: $font-down-1;
color: var(--primary-medium);
}
&:hover {
background: var(--primary-low);
}
}
.sidebar-section-link-wrapper {
margin-left: 1.5em;
display: flex;
align-items: center;
}
.sidebar-section-link {
flex: 1 1 0;
display: flex;
padding: 0.25em 0.5em;
color: var(--primary-high);
font-size: var(--font-down-1);
&:hover {
background: var(--primary-low);
}
&.active {
color: var(--primary);
font-weight: bold;
}
}
.sidebar-section-link-content-badge {
color: var(--tertiary);
font-size: var(--font-down-1);
font-weight: normal;
margin-left: auto;
}
.sidebar-section-header-caret {
width: 1.5em;
display: flex;
justify-content: center;
border: none;
background-color: transparent;
&:hover {
opacity: 100;
}
opacity: 0;
.d-icon {
font-size: $font-down-1;
color: var(--primary-medium);
}
}
}

View File

@ -4030,6 +4030,23 @@ en:
second_factor_auth:
redirect_after_success: "Second factor authentication is successful. Redirecting to the previous page…"
sidebar:
sections:
links:
badge:
unread_count: "%{count} unread"
new_count: "%{count} new"
everything:
content: "Everything"
title: "All topics"
tracked:
content: "Tracked"
title: "All tracked topics"
bookmarked:
content: "Bookmarked"
title: "All bookmarked topics"
# This section is exported to the javascript for i18n in the admin section
admin_js:
type_to_filter: "type to filter..."