DEV: Refactor discovery routes to remove use of 'named outlets' (#22622)

The motivation of this PR is to remove our dependence on Ember's 'named outlets', which are removed in Ember 4+.

At a high-level, the changes can be summarized as:

- The top-level `discovery` route is totally emptied of all logic. The HTML structure of the template is moved into the `<Discovery::Layout />` component for use by child routes.

- `AbstractTopicRoute` and `AbstractCategoryRoute` routes now both lean on the `DiscoverySortableController` and associated template. This controller is where most of the logic from the old top-level `discovery` controller has ended up.

- All navigation controllers/templates have been replaced with components. `navigation/categories`, `navigation/category` and `navigation/default` were very similar, and so they've all been combined into `<Navigation::Default>`. `navigation/filter` gets its own component.

- The `discovery/topics` controller/template have been moved into a new `<Discovery::Topics>` component.

Various other parts of the app have been tweaked to support these changes, but I've tried to keep that to a minimum.

Anything from `<TopicList>` down is untouched, which should hopefully mean that a large proportion of topic-list-customizing themes are unaffected.

For more information, see https://meta.discourse.org/t/282816
This commit is contained in:
David Taylor 2023-11-06 10:39:31 +00:00 committed by GitHub
parent fe769994d1
commit 82d6d691ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 1241 additions and 1739 deletions

View File

@ -5,13 +5,9 @@
@hideCategory={{this.hideCategory}} @hideCategory={{this.hideCategory}}
@topics={{this.topics}} @topics={{this.topics}}
@expandExcerpts={{this.expandExcerpts}} @expandExcerpts={{this.expandExcerpts}}
@bulkSelectEnabled={{this.bulkSelectEnabled}} @bulkSelectHelper={{this.bulkSelectHelper}}
@bulkSelectAction={{this.bulkSelectAction}}
@canBulkSelect={{this.canBulkSelect}} @canBulkSelect={{this.canBulkSelect}}
@selected={{this.selected}}
@tagsForUser={{this.tagsForUser}} @tagsForUser={{this.tagsForUser}}
@toggleBulkSelect={{this.toggleBulkSelect}}
@updateAutoAddTopicsToBulkSelect={{this.updateAutoAddTopicsToBulkSelect}}
/> />
{{else}} {{else}}
{{#unless this.loadingMore}} {{#unless this.loadingMore}}

View File

@ -0,0 +1,11 @@
import DButton from "discourse/components/d-button";
const BulkSelectToggle = <template>
<DButton
class="bulk-select"
@action={{@bulkSelectHelper.toggleBulkSelect}}
@icon="list"
/>
</template>;
export default BulkSelectToggle;

View File

@ -1 +0,0 @@
<DButton class="bulk-select" @action={{this.toggleBulkSelect}} @icon="list" />

View File

@ -1,15 +0,0 @@
import Component from "@glimmer/component";
import { getOwner } from "@ember/application";
import { action } from "@ember/object";
export default class BulkSelectToggle extends Component {
@action
toggleBulkSelect() {
const controller = getOwner(this).lookup(
`controller:${this.args.parentController}`
);
const helper = controller.bulkSelectHelper;
helper.clear();
helper.bulkSelectEnabled = !helper.bulkSelectEnabled;
}
}

View File

@ -17,8 +17,8 @@
{{/unless}} {{/unless}}
<div class="navigation-controls"> <div class="navigation-controls">
{{#if (and this.notCategoriesRoute this.site.mobileView this.canBulk)}} {{#if (and this.notCategoriesRoute this.site.mobileView @canBulkSelect)}}
<BulkSelectToggle @parentController="discovery/topics" /> <BulkSelectToggle @bulkSelectHelper={{@bulkSelectHelper}} />
{{/if}} {{/if}}
{{#if this.showCategoryAdmin}} {{#if this.showCategoryAdmin}}

View File

@ -1,6 +1,6 @@
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { getOwner } from "@ember/application";
import Component from "@ember/component"; import Component from "@ember/component";
import { action } from "@ember/object";
import { dependentKeyCompat } from "@ember/object/compat"; import { dependentKeyCompat } from "@ember/object/compat";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
@ -142,10 +142,23 @@ export default Component.extend({
return filterType !== "categories"; return filterType !== "categories";
}, },
@discourseComputed() @action
canBulk() { async changeTagNotificationLevel(notificationLevel) {
const controller = getOwner(this).lookup("controller:discovery/topics"); const response = await this.tagNotification.update({
return controller.canBulkSelect; notification_level: notificationLevel,
});
const payload = response.responseJson;
this.tagNotification.set("notification_level", notificationLevel);
this.currentUser.setProperties({
watched_tags: payload.watched_tags,
watching_first_post_tags: payload.watching_first_post_tags,
tracked_tags: payload.tracked_tags,
muted_tags: payload.muted_tags,
regular_tags: payload.regular_tags,
});
}, },
actions: { actions: {

View File

@ -1,10 +1,13 @@
import Component from "@ember/component"; import Component from "@ember/component";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { readOnly } from "@ember/object/computed";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
export default Component.extend({ export default Component.extend({
hide: false, hide: false,
banner: readOnly("site.banner"),
@discourseComputed("banner.html") @discourseComputed("banner.html")
content(bannerHtml) { content(bannerHtml) {
const newDiv = document.createElement("div"); const newDiv = document.createElement("div");
@ -15,7 +18,7 @@ export default Component.extend({
return newDiv.innerHTML; return newDiv.innerHTML;
}, },
@discourseComputed("user.dismissed_banner_key", "banner.key", "hide") @discourseComputed("currentUser.dismissed_banner_key", "banner.key", "hide")
visible(dismissedBannerKey, bannerKey, hide) { visible(dismissedBannerKey, bannerKey, hide) {
dismissedBannerKey = dismissedBannerKey =
dismissedBannerKey || this.keyValueStore.get("dismissed_banner_key"); dismissedBannerKey || this.keyValueStore.get("dismissed_banner_key");
@ -32,8 +35,8 @@ export default Component.extend({
@action @action
dismiss() { dismiss() {
if (this.user) { if (this.currentUser) {
this.user.dismissBanner(this.get("banner.key")); this.currentUser.dismissBanner(this.get("banner.key"));
} else { } else {
this.set("hide", true); this.set("hide", true);
this.keyValueStore.set({ this.keyValueStore.set({

View File

@ -1,20 +0,0 @@
import Component from "@ember/component";
import UrlRefresh from "discourse/mixins/url-refresh";
const CATEGORIES_LIST_BODY_CLASS = "categories-list";
export default Component.extend(UrlRefresh, {
classNames: ["contents"],
didInsertElement() {
this._super(...arguments);
document.body.classList.add(CATEGORIES_LIST_BODY_CLASS);
},
willDestroyElement() {
this._super(...arguments);
document.body.classList.remove(CATEGORIES_LIST_BODY_CLASS);
},
});

View File

@ -2,10 +2,9 @@ import Component from "@ember/component";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import $ from "jquery"; import $ from "jquery";
import LoadMore from "discourse/mixins/load-more"; import LoadMore from "discourse/mixins/load-more";
import UrlRefresh from "discourse/mixins/url-refresh";
import { observes, on } from "discourse-common/utils/decorators"; import { observes, on } from "discourse-common/utils/decorators";
export default Component.extend(UrlRefresh, LoadMore, { export default Component.extend(LoadMore, {
classNames: ["contents"], classNames: ["contents"],
eyelineSelector: ".topic-list-item", eyelineSelector: ".topic-list-item",
documentTitle: service(), documentTitle: service(),
@ -40,17 +39,13 @@ export default Component.extend(UrlRefresh, LoadMore, {
if ( if (
newTopics && newTopics &&
newTopics.length && newTopics.length &&
this.autoAddTopicsToBulkSelect && this.bulkSelectHelper?.bulkSelectEnabled
this.bulkSelectEnabled
) { ) {
this.addTopicsToBulkSelect(newTopics); this.bulkSelectHelper.addTopics(newTopics);
} }
if (moreTopicsUrl && $(window).height() >= $(document).height()) { if (moreTopicsUrl && $(window).height() >= $(document).height()) {
this.send("loadMore"); this.send("loadMore");
} }
if (this.loadingComplete) {
this.loadingComplete();
}
}); });
}, },
}, },

View File

@ -0,0 +1,77 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import CategoriesAndLatestTopics from "discourse/components/categories-and-latest-topics";
import CategoriesBoxes from "discourse/components/categories-boxes";
import CategoriesBoxesWithTopics from "discourse/components/categories-boxes-with-topics";
import CategoriesOnly from "discourse/components/categories-only";
import CategoriesWithFeaturedTopics from "discourse/components/categories-with-featured-topics";
import SubcategoriesWithFeaturedTopics from "discourse/components/subcategories-with-featured-topics";
const mobileCompatibleViews = [
"categories_with_featured_topics",
"subcategories_with_featured_topics",
];
const subcategoryComponents = {
boxes_with_featured_topics: CategoriesBoxesWithTopics,
boxes: CategoriesBoxes,
rows_with_featured_topics: CategoriesWithFeaturedTopics,
rows: CategoriesOnly,
};
const globalComponents = {
categories_and_latest_topics_created_date: CategoriesAndLatestTopics,
categories_and_latest_topics: CategoriesAndLatestTopics,
categories_boxes_with_topics: CategoriesBoxesWithTopics,
categories_boxes: CategoriesBoxes,
categories_only: CategoriesOnly,
categories_with_featured_topics: CategoriesWithFeaturedTopics,
subcategories_with_featured_topics: SubcategoriesWithFeaturedTopics,
};
export default class CategoriesDisplay extends Component {
@service siteSettings;
@service site;
get #componentForSubcategories() {
const parentCategory = this.args.parentCategory;
const style = parentCategory.subcategory_list_style;
const component = subcategoryComponents[style];
if (!component) {
// eslint-disable-next-line no-console
console.error("Unknown subcategory list style: " + style);
return CategoriesOnly;
}
return component;
}
get #globalComponent() {
let style = this.siteSettings.desktop_category_page_style;
if (this.site.mobileView && !mobileCompatibleViews.includes(style)) {
style = mobileCompatibleViews[0];
}
const component = globalComponents[style];
if (!component) {
// eslint-disable-next-line no-console
console.error("Unknown category list style: " + style);
return CategoriesOnly;
}
return component;
}
get categoriesComponent() {
if (this.args.parentCategory) {
return this.#componentForSubcategories;
} else {
return this.#globalComponent;
}
}
<template>
<this.categoriesComponent @categories={{@categories}} @topics={{@topics}} />
</template>
}

View File

@ -7,10 +7,7 @@
<Input <Input
class="topic-query-filter__filter-term" class="topic-query-filter__filter-term"
@value={{this.newQueryString}} @value={{this.newQueryString}}
@enter={{action @enter={{action @updateTopicsListQueryParams this.newQueryString}}
this.discoveryFilter.updateTopicsListQueryParams
this.newQueryString
}}
@type="text" @type="text"
id="queryStringInput" id="queryStringInput"
autocomplete="off" autocomplete="off"

View File

@ -1,15 +1,14 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import Controller, { inject as controller } from "@ember/controller";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { resettableTracked } from "discourse/lib/tracked-tools";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
export default class NavigationFilterController extends Controller { export default class DiscoveryFilterNavigation extends Component {
@controller("discovery/filter") discoveryFilter;
@tracked copyIcon = "link"; @tracked copyIcon = "link";
@tracked copyClass = "btn-default"; @tracked copyClass = "btn-default";
@tracked newQueryString = ""; @resettableTracked newQueryString = this.args.queryString;
@bind @bind
updateQueryString(string) { updateQueryString(string) {
@ -19,7 +18,7 @@ export default class NavigationFilterController extends Controller {
@action @action
clearInput() { clearInput() {
this.newQueryString = ""; this.newQueryString = "";
this.discoveryFilter.updateTopicsListQueryParams(this.newQueryString); this.args.updateTopicsListQueryParams(this.newQueryString);
} }
@action @action

View File

@ -1,11 +1,11 @@
<div class="container"> <div class="container">
<DiscourseBanner @user={{this.currentUser}} @banner={{this.site.banner}} /> <DiscourseBanner />
{{#unless this.viewingCategoriesList}} {{#if @category}}
<CategoryReadOnlyBanner <CategoryReadOnlyBanner
@category={{this.category}} @category={{@category}}
@readOnly={{not this.navigationCategory.enableCreateTopicButton}} @readOnly={{@createTopicDisabled}}
/> />
{{/unless}} {{/if}}
</div> </div>
<span> <span>
@ -18,28 +18,22 @@
@connectorTagName="div" @connectorTagName="div"
/> />
<div class="container"> <div class="container">
{{outlet "navigation-bar"}} {{yield to="navigation"}}
</div> </div>
</div> </div>
<ConditionalLoadingSpinner @condition={{this.showLoadingSpinner}} />
{{#if this.loading}}
{{hide-application-footer}}
{{/if}}
<span> <span>
<PluginOutlet @name="discovery-above" @connectorTagName="div" /> <PluginOutlet @name="discovery-above" @connectorTagName="div" />
</span> </span>
<div class="container list-container {{if this.showLoadingSpinner 'hidden'}}"> <div class="container list-container">
<div class="row"> <div class="row">
<div class="full-width"> <div class="full-width">
<div id="header-list-area"> <div id="header-list-area">
{{outlet "header-list-container"}} {{yield to="header"}}
<PluginOutlet <PluginOutlet
@name="header-list-container-bottom" @name="header-list-container-bottom"
@outletArgs={{hash category=this.category}} @outletArgs={{hash category=@category}}
/> />
</div> </div>
</div> </div>
@ -51,9 +45,9 @@
<PluginOutlet <PluginOutlet
@name="discovery-list-container-top" @name="discovery-list-container-top"
@connectorTagName="span" @connectorTagName="span"
@outletArgs={{hash category=this.category listLoading=this.loading}} @outletArgs={{hash category=@category}}
/> />
{{outlet "list-container"}} {{yield to="list"}}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,72 @@
<AddCategoryTagClasses @category={{@category}} @tags={{array @tag.id}} />
{{#if @category}}
<PluginOutlet
@name="above-category-heading"
@outletArgs={{hash category=@category}}
/>
<section class="category-heading">
{{#if @category.uploaded_logo.url}}
<CategoryLogo @category={{@category}} />
{{#if @category.description}}
<p>{{dir-span @category.description htmlSafe="true"}}</p>
{{/if}}
{{/if}}
<span>
<PluginOutlet
@name="category-heading"
@connectorTagName="div"
@outletArgs={{hash category=@category}}
/>
</span>
</section>
{{/if}}
{{body-class this.bodyClass}}
<section
class={{concat-class
"navigation-container"
(if @category "category-navigation")
}}
>
<DNavigation
@category={{@category}}
@tag={{@tag}}
@additionalTags={{@additionalTags}}
@filterMode={{this.filterMode}}
@noSubcategories={{@noSubcategories}}
@canCreateTopic={{this.canCreateTopic}}
@canCreateTopicOnTag={{@canCreateTopicOnTag}}
@createTopic={{@createTopic}}
@createTopicDisabled={{@createTopicDisabled}}
@hasDraft={{this.currentUser.has_topic_draft}}
@editCategory={{this.editCategory}}
@showCategoryAdmin={{@showCategoryAdmin}}
@createCategory={{this.createCategory}}
@reorderCategories={{this.reorderCategories}}
@canBulkSelect={{@canBulkSelect}}
@bulkSelectHelper={{@bulkSelectHelper}}
@skipCategoriesNavItem={{this.skipCategoriesNavItem}}
@toggleInfo={{@toggleTagInfo}}
@tagNotification={{@tagNotification}}
/>
{{#if @category}}
<PluginOutlet
@name="category-navigation"
@connectorTagName="div"
@outletArgs={{hash category=@category tag=@tag}}
/>
{{/if}}
{{#if @tag}}
<PluginOutlet
@name="tag-navigation"
@connectorTagName="div"
@outletArgs={{hash category=@category tag=@tag}}
/>
{{/if}}
</section>

View File

@ -0,0 +1,61 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { calculateFilterMode } from "discourse/lib/filter-mode";
import showModal from "discourse/lib/show-modal";
import { TRACKED_QUERY_PARAM_VALUE } from "discourse/lib/topic-list-tracked-filter";
import DiscourseURL from "discourse/lib/url";
import Category from "discourse/models/category";
export default class DiscoveryNavigation extends Component {
@service router;
@service currentUser;
get filterMode() {
return calculateFilterMode({
category: this.args.category,
filterType: this.args.filterType,
noSubcategories: this.args.noSubcategories,
});
}
get skipCategoriesNavItem() {
return this.router.currentRoute.queryParams.f === TRACKED_QUERY_PARAM_VALUE;
}
get canCreateTopic() {
return this.currentUser?.can_create_topic;
}
get bodyClass() {
if (this.args.tag) {
return [
"tags-page",
this.args.additionalTags ? "tags-intersection" : null,
]
.filter(Boolean)
.join(" ");
} else if (this.filterMode === "categories") {
return "navigation-categories";
} else if (this.args.category) {
return "navigation-category";
} else {
return "navigation-topics";
}
}
@action
editCategory() {
DiscourseURL.routeTo(`/c/${Category.slugFor(this.args.category)}/edit`);
}
@action
createCategory() {
this.router.transitionTo("newCategory");
}
@action
reorderCategories() {
showModal("reorder-categories");
}
}

View File

@ -1,4 +1,4 @@
{{#if (or this.loading this.model.canLoadMore)}} {{#if @model.canLoadMore}}
{{hide-application-footer}} {{hide-application-footer}}
{{/if}} {{/if}}
@ -8,39 +8,36 @@
<TopicDismissButtons <TopicDismissButtons
@position="top" @position="top"
@selectedTopics={{this.selected}} @selectedTopics={{@bulkSelectHelper.selected}}
@model={{this.model}} @model={{@model}}
@showResetNew={{this.showResetNew}} @showResetNew={{@showResetNew}}
@showDismissRead={{this.showDismissRead}} @showDismissRead={{@showDismissRead}}
@resetNew={{action "resetNew"}} @resetNew={{this.resetNew}}
@dismissRead={{this.dismissRead}}
/> />
{{#if this.model.sharedDrafts}} {{#if @model.sharedDrafts}}
<TopicList <TopicList
@class="shared-drafts" @class="shared-drafts"
@listTitle="shared_drafts.title" @listTitle="shared_drafts.title"
@top={{this.top}} @top={{this.top}}
@hideCategory="true" @hideCategory="true"
@category={{this.category}} @category={{@category}}
@topics={{this.model.sharedDrafts}} @topics={{@model.sharedDrafts}}
@discoveryList={{true}} @discoveryList={{true}}
/> />
{{/if}} {{/if}}
<DiscoveryTopicsList <DiscoveryTopicsList
@model={{this.model}} @model={{@model}}
@refresh={{action "refresh"}}
@loadingComplete={{action "loadingComplete"}}
@incomingCount={{this.topicTrackingState.incomingCount}} @incomingCount={{this.topicTrackingState.incomingCount}}
@autoAddTopicsToBulkSelect={{this.autoAddTopicsToBulkSelect}} @bulkSelectHelper={{@bulkSelectHelper}}
@bulkSelectEnabled={{this.bulkSelectEnabled}}
@addTopicsToBulkSelect={{action "addTopicsToBulkSelect"}}
> >
{{#if this.top}} {{#if this.top}}
<div class="top-lists"> <div class="top-lists">
<PeriodChooser <PeriodChooser
@period={{this.period}} @period={{@period}}
@action={{action "changePeriod"}} @action={{@changePeriod}}
@fullDay={{false}} @fullDay={{false}}
/> />
</div> </div>
@ -65,10 +62,10 @@
{{#if this.renderNewListHeaderControls}} {{#if this.renderNewListHeaderControls}}
<NewListHeaderControlsWrapper <NewListHeaderControlsWrapper
@current={{this.model.params.subset}} @current={{@model.params.subset}}
@newRepliesCount={{this.newRepliesCount}} @newRepliesCount={{this.newRepliesCount}}
@newTopicsCount={{this.newTopicsCount}} @newTopicsCount={{this.newTopicsCount}}
@changeNewListSubset={{route-action "changeNewListSubset"}} @changeNewListSubset={{@changeNewListSubset}}
/> />
{{/if}} {{/if}}
@ -76,7 +73,7 @@
<PluginOutlet <PluginOutlet
@name="before-topic-list" @name="before-topic-list"
@connectorTagName="div" @connectorTagName="div"
@outletArgs={{hash category=this.category}} @outletArgs={{hash category=@category tag=@tag}}
/> />
</span> </span>
@ -86,27 +83,21 @@
@top={{this.top}} @top={{this.top}}
@showTopicPostBadges={{this.showTopicPostBadges}} @showTopicPostBadges={{this.showTopicPostBadges}}
@showPosters={{true}} @showPosters={{true}}
@canBulkSelect={{this.canBulkSelect}} @canBulkSelect={{@canBulkSelect}}
@changeSort={{route-action "changeSort"}} @bulkSelectHelper={{@bulkSelectHelper}}
@toggleBulkSelect={{action "toggleBulkSelect"}} @changeSort={{@changeSort}}
@updateAutoAddTopicsToBulkSelect={{action @hideCategory={{@model.hideCategory}}
"updateAutoAddTopicsToBulkSelect"
}}
@hideCategory={{this.model.hideCategory}}
@order={{this.order}} @order={{this.order}}
@ascending={{this.ascending}} @ascending={{this.ascending}}
@bulkSelectEnabled={{this.bulkSelectEnabled}}
@bulkSelectAction={{action "refresh"}}
@selected={{this.selected}}
@expandGloballyPinned={{this.expandGloballyPinned}} @expandGloballyPinned={{this.expandGloballyPinned}}
@expandAllPinned={{this.expandAllPinned}} @expandAllPinned={{this.expandAllPinned}}
@category={{this.category}} @category={{@category}}
@topics={{this.model.topics}} @topics={{@model.topics}}
@discoveryList={{true}} @discoveryList={{true}}
@focusLastVisitedTopic={{true}} @focusLastVisitedTopic={{true}}
@showTopicsAndRepliesToggle={{this.showTopicsAndRepliesToggle}} @showTopicsAndRepliesToggle={{this.showTopicsAndRepliesToggle}}
@newListSubset={{this.model.params.subset}} @newListSubset={{@model.params.subset}}
@changeNewListSubset={{route-action "changeNewListSubset"}} @changeNewListSubset={{@changeNewListSubset}}
@newRepliesCount={{this.newRepliesCount}} @newRepliesCount={{this.newRepliesCount}}
@newTopicsCount={{this.newTopicsCount}} @newTopicsCount={{this.newTopicsCount}}
/> />
@ -115,33 +106,38 @@
<PluginOutlet <PluginOutlet
@name="after-topic-list" @name="after-topic-list"
@connectorTagName="div" @connectorTagName="div"
@outletArgs={{hash category=this.category}} @outletArgs={{hash category=@category tag=@tag}}
/> />
</span> </span>
</DiscoveryTopicsList> </DiscoveryTopicsList>
<footer class="topic-list-bottom"> <footer class="topic-list-bottom">
<ConditionalLoadingSpinner @condition={{this.model.loadingMore}} /> <ConditionalLoadingSpinner @condition={{@model.loadingMore}} />
{{#if this.allLoaded}} {{#if this.allLoaded}}
<TopicDismissButtons <TopicDismissButtons
@position="bottom" @position="bottom"
@selectedTopics={{this.selected}} @selectedTopics={{@bulkSelectHelper.selected}}
@model={{this.model}} @model={{@model}}
@showResetNew={{this.showResetNew}} @showResetNew={{@showResetNew}}
@showDismissRead={{this.showDismissRead}} @showDismissRead={{@showDismissRead}}
@resetNew={{action "resetNew"}} @resetNew={{this.resetNew}}
@dismissRead={{this.dismissRead}}
/> />
<FooterMessage <FooterMessage
@education={{this.footerEducation}} @education={{this.footerEducation}}
@message={{this.footerMessage}} @message={{this.footerMessage}}
> >
{{#if this.latest}} {{#if @tag}}
{{#if this.category.canCreateTopic}} {{html-safe
(i18n "topic.browse_all_tags_or_latest" basePath=(base-path))
}}
{{else if this.latest}}
{{#if @category.canCreateTopic}}
<DiscourseLinkedText <DiscourseLinkedText
@action={{fn @action={{fn
this.composer.openNewTopic this.composer.openNewTopic
(hash category=this.category preferDraft=true) (hash category=@category preferDraft=true)
}} }}
@text="topic.suggest_create_topic" @text="topic.suggest_create_topic"
/> />
@ -152,10 +148,7 @@
"topic.browse_all_categories_latest_or_top" basePath=(base-path) "topic.browse_all_categories_latest_or_top" basePath=(base-path)
) )
}} }}
<TopPeriodButtons <TopPeriodButtons @period={{@period}} @action={{@changePeriod}} />
@period={{this.period}}
@action={{action "changePeriod"}}
/>
{{else}} {{else}}
{{html-safe {{html-safe
(i18n "topic.browse_all_categories_latest" basePath=(base-path)) (i18n "topic.browse_all_categories_latest" basePath=(base-path))

View File

@ -0,0 +1,223 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DismissNew from "discourse/components/modal/dismiss-new";
import { filterTypeForMode } from "discourse/lib/filter-mode";
import { userPath } from "discourse/lib/url";
import Topic from "discourse/models/topic";
import I18n from "discourse-i18n";
export default class DiscoveryTopics extends Component {
@service router;
@service composer;
@service modal;
@service currentUser;
@service topicTrackingState;
@service site;
get redirectedReason() {
return this.currentUser?.user_option.redirected_to_top?.reason;
}
get order() {
return this.args.model.get("params.order");
}
get ascending() {
return this.args.model.get("params.ascending");
}
get hasTopics() {
return this.args.model.get("topics.length") > 0;
}
get allLoaded() {
return !this.args.model.get("more_topics_url");
}
get latest() {
return filterTypeForMode(this.args.model.filter) === "latest";
}
get top() {
return filterTypeForMode(this.args.model.filter) === "top";
}
get new() {
return filterTypeForMode(this.args.model.filter) === "new";
}
async callResetNew(
dismissPosts = false,
dismissTopics = false,
untrack = false
) {
const tracked =
(this.router.currentRoute.queryParams["f"] ||
this.router.currentRoute.queryParams["filter"]) === "tracked";
let topicIds = this.args.bulkSelectHelper.selected.map((topic) => topic.id);
const result = await Topic.resetNew(
this.args.category,
!this.args.noSubcategories,
{
tracked,
tag: this.args.tag,
topicIds,
dismissPosts,
dismissTopics,
untrack,
}
);
if (result.topic_ids) {
this.topicTrackingState.removeTopics(result.topic_ids);
}
this.router.refresh();
}
@action
resetNew() {
if (!this.currentUser.new_new_view_enabled) {
return this.callResetNew();
}
this.modal.show(DismissNew, {
model: {
selectedTopics: this.args.bulkSelectHelper.selected,
subset: this.args.model.listParams?.subset,
dismissCallback: ({ dismissPosts, dismissTopics, untrack }) => {
this.callResetNew(dismissPosts, dismissTopics, untrack);
},
},
});
}
// Show newly inserted topics
@action
showInserted(event) {
event?.preventDefault();
const tracker = this.topicTrackingState;
// Move inserted into topics
this.args.model.loadBefore(tracker.get("newIncoming"), true);
tracker.resetTracking();
}
get showTopicsAndRepliesToggle() {
return this.new && this.currentUser?.new_new_view_enabled;
}
get newRepliesCount() {
this.topicTrackingState.get("messageCount"); // Autotrack this
if (this.currentUser?.new_new_view_enabled) {
return this.topicTrackingState.countUnread({
categoryId: this.args.category?.id,
noSubcategories: this.args.noSubcategories,
tagId: this.args.tag?.id,
});
} else {
return 0;
}
}
get newTopicsCount() {
this.topicTrackingState.get("messageCount"); // Autotrack this
if (this.currentUser?.new_new_view_enabled) {
return this.topicTrackingState.countNew({
categoryId: this.args.category?.id,
noSubcategories: this.args.noSubcategories,
tagId: this.args.tag?.id,
});
} else {
return 0;
}
}
get showTopicPostBadges() {
return !this.new || this.currentUser?.new_new_view_enabled;
}
get footerMessage() {
const topicsLength = this.args.model.get("topics.length");
if (!this.allLoaded) {
return;
}
const { category, tag } = this.args;
if (category) {
return I18n.t("topics.bottom.category", {
category: category.get("name"),
});
} else if (tag) {
return I18n.t("topics.bottom.tag", {
tag: tag.id,
});
} else {
const split = (this.args.model.get("filter") || "").split("/");
if (topicsLength === 0) {
return I18n.t("topics.none." + split[0], {
category: split[1],
});
} else {
return I18n.t("topics.bottom." + split[0], {
category: split[1],
});
}
}
}
get footerEducation() {
const topicsLength = this.args.model.get("topics.length");
if (!this.allLoaded || topicsLength > 0 || !this.currentUser) {
return;
}
const segments = (this.args.model.get("filter") || "").split("/");
let tab = segments[segments.length - 1];
if (tab !== "new" && tab !== "unread") {
return;
}
if (tab === "new" && this.currentUser.new_new_view_enabled) {
tab = "new_new";
}
return I18n.t("topics.none.educate." + tab, {
userPrefsUrl: userPath(
`${this.currentUser.get("username_lower")}/preferences/tracking`
),
});
}
get renderNewListHeaderControls() {
return (
this.site.mobileView &&
this.showTopicsAndRepliesToggle &&
!this.args.bulkSelectEnabled
);
}
get expandAllGloballyPinned() {
return !this.expandAllPinned;
}
get expandAllPinned() {
return this.args.tag || this.args.category;
}
@action
dismissRead(dismissTopics) {
const operationType = dismissTopics ? "topics" : "posts";
this.args.bulkSelectHelper.dismissRead(operationType, {
categoryId: this.args.category?.id,
tagName: this.args.tag?.id,
includeSubcategories: this.args.noSubcategories,
});
}
}

View File

@ -15,7 +15,7 @@
<:footer> <:footer>
<DButton <DButton
class="btn-primary" class="btn-primary"
@action={{route-action "dismissReadTopics" this.dismissTopics}} @action={{fn @model.dismissRead this.dismissTopics}}
@icon="check" @icon="check"
id="dismiss-read-confirm" id="dismiss-read-confirm"
@label="topics.bulk.dismiss" @label="topics.bulk.dismiss"

View File

@ -146,7 +146,7 @@
id="edit-synonyms" id="edit-synonyms"
class="btn-default" class="btn-default"
/> />
{{#if this.deleteAction}} {{#if this.canAdminTag}}
<DButton <DButton
@action={{action "deleteTag"}} @action={{action "deleteTag"}}
@icon="far-trash-alt" @icon="far-trash-alt"

View File

@ -140,8 +140,35 @@ export default Component.extend({
.catch(popupAjaxError); .catch(popupAjaxError);
}, },
@action
deleteTag() { deleteTag() {
this.deleteAction(this.tagInfo); const numTopics =
this.get("list.topic_list.tags.firstObject.topic_count") || 0;
let confirmText =
numTopics === 0
? I18n.t("tagging.delete_confirm_no_topics")
: I18n.t("tagging.delete_confirm", { count: numTopics });
if (this.tagInfo.synonyms.length > 0) {
confirmText +=
" " +
I18n.t("tagging.delete_confirm_synonyms", {
count: this.tagInfo.synonyms.length,
});
}
this.dialog.deleteConfirm({
message: confirmText,
didConfirm: async () => {
try {
await this.tag.destroyRecord();
this.router.transitionTo("tags.index");
} catch {
this.dialog.alert(I18n.t("generic_error"));
}
},
});
}, },
addSynonyms() { addSynonyms() {

View File

@ -72,6 +72,7 @@ export default Component.extend({
model: { model: {
title: dismissTitle, title: dismissTitle,
count: this.selectedTopics.length, count: this.selectedTopics.length,
dismissRead: this.dismissRead,
}, },
}); });
}, },

View File

@ -1,5 +1,6 @@
import Component from "@ember/component"; import Component from "@ember/component";
import { alias, and } from "@ember/object/computed"; import { dependentKeyCompat } from "@ember/object/compat";
import { alias } from "@ember/object/computed";
import { on } from "@ember/object/evented"; import { on } from "@ember/object/evented";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import LoadMore from "discourse/mixins/load-more"; import LoadMore from "discourse/mixins/load-more";
@ -8,13 +9,19 @@ import TopicBulkActions from "./modal/topic-bulk-actions";
export default Component.extend(LoadMore, { export default Component.extend(LoadMore, {
modal: service(), modal: service(),
router: service(),
tagName: "table", tagName: "table",
classNames: ["topic-list"], classNames: ["topic-list"],
classNameBindings: ["bulkSelectEnabled:sticky-header"], classNameBindings: ["bulkSelectEnabled:sticky-header"],
showTopicPostBadges: true, showTopicPostBadges: true,
listTitle: "topic.title", listTitle: "topic.title",
canDoBulkActions: and("currentUser.canManageTopic", "selected.length"),
get canDoBulkActions() {
return (
this.currentUser?.canManageTopic && this.bulkSelectHelper?.selected.length
);
},
// Overwrite this to perform client side filtering of topics, if desired // Overwrite this to perform client side filtering of topics, if desired
filteredTopics: alias("topics"), filteredTopics: alias("topics"),
@ -26,9 +33,17 @@ export default Component.extend(LoadMore, {
this.refreshLastVisited(); this.refreshLastVisited();
}), }),
@discourseComputed("bulkSelectEnabled") get selected() {
toggleInTitle(bulkSelectEnabled) { return this.bulkSelectHelper?.selected;
return !bulkSelectEnabled && this.canBulkSelect; },
@dependentKeyCompat // for the classNameBindings
get bulkSelectEnabled() {
return this.bulkSelectHelper?.bulkSelectEnabled;
},
get toggleInTitle() {
return !this.bulkSelectHelper?.bulkSelectEnabled && this.canBulkSelect;
}, },
@discourseComputed @discourseComputed
@ -138,10 +153,6 @@ export default Component.extend(LoadMore, {
); );
}, },
updateAutoAddTopicsToBulkSelect(newVal) {
this.set("autoAddTopicsToBulkSelect", newVal);
},
click(e) { click(e) {
const onClick = (sel, callback) => { const onClick = (sel, callback) => {
let target = e.target.closest(sel); let target = e.target.closest(sel);
@ -152,19 +163,19 @@ export default Component.extend(LoadMore, {
}; };
onClick("button.bulk-select", () => { onClick("button.bulk-select", () => {
this.toggleBulkSelect(); this.bulkSelectHelper.toggleBulkSelect();
this.rerender(); this.rerender();
}); });
onClick("button.bulk-select-all", () => { onClick("button.bulk-select-all", () => {
this.updateAutoAddTopicsToBulkSelect(true); this.bulkSelectHelper.autoAddTopicsToBulkSelect = true;
document document
.querySelectorAll("input.bulk-select:not(:checked)") .querySelectorAll("input.bulk-select:not(:checked)")
.forEach((el) => el.click()); .forEach((el) => el.click());
}); });
onClick("button.bulk-clear-all", () => { onClick("button.bulk-clear-all", () => {
this.updateAutoAddTopicsToBulkSelect(false); this.bulkSelectHelper.autoAddTopicsToBulkSelect = false;
document document
.querySelectorAll("input.bulk-select:checked") .querySelectorAll("input.bulk-select:checked")
.forEach((el) => el.click()); .forEach((el) => el.click());
@ -178,9 +189,9 @@ export default Component.extend(LoadMore, {
onClick("button.bulk-select-actions", () => { onClick("button.bulk-select-actions", () => {
this.modal.show(TopicBulkActions, { this.modal.show(TopicBulkActions, {
model: { model: {
topics: this.selected, topics: this.bulkSelectHelper.selected,
category: this.category, category: this.category,
refreshClosure: this.bulkSelectAction, refreshClosure: () => this.router.refresh(),
}, },
}); });
}); });

View File

@ -1,69 +0,0 @@
import Controller, { inject as controller } from "@ember/controller";
// Just add query params here to have them automatically passed to topic list filters.
export const queryParams = {
order: { replace: true, refreshModel: true },
ascending: { replace: true, refreshModel: true, default: false },
status: { replace: true, refreshModel: true },
state: { replace: true, refreshModel: true },
search: { replace: true, refreshModel: true },
max_posts: { replace: true, refreshModel: true },
min_posts: { replace: true, refreshModel: true },
q: { replace: true, refreshModel: true },
before: { replace: true, refreshModel: true },
bumped_before: { replace: true, refreshModel: true },
f: { replace: true, refreshModel: true },
subset: { replace: true, refreshModel: true },
period: { replace: true, refreshModel: true },
topic_ids: { replace: true, refreshModel: true },
group_name: { replace: true, refreshModel: true },
tags: { replace: true, refreshModel: true },
match_all_tags: { replace: true, refreshModel: true },
no_subcategories: { replace: true, refreshModel: true },
no_tags: { replace: true, refreshModel: true },
exclude_tag: { replace: true, refreshModel: true },
};
export function changeSort(sortBy) {
let model = this.controllerFor("discovery.topics").model;
if (sortBy === this.controller.order) {
this.controller.toggleProperty("ascending");
model.updateSortParams(sortBy, this.controller.ascending);
} else {
this.controller.setProperties({ order: sortBy, ascending: false });
model.updateSortParams(sortBy, false);
}
}
export function changeNewListSubset(subset) {
this.controller.set("subset", subset);
let model = this.controllerFor("discovery.topics").model;
model.updateNewListSubsetParam(subset);
}
export function resetParams(skipParams = []) {
Object.keys(queryParams).forEach((p) => {
if (!skipParams.includes(p)) {
this.controller.set(p, queryParams[p].default);
}
});
}
export function addDiscoveryQueryParam(p, opts) {
queryParams[p] = opts;
}
export default class DiscoverySortableController extends Controller {
@controller("discovery/topics") discoveryTopics;
queryParams = Object.keys(queryParams);
constructor() {
super(...arguments);
this.queryParams.forEach((p) => {
this[p] = queryParams[p].default;
});
}
}

View File

@ -1,69 +0,0 @@
import Controller, { inject as controller } from "@ember/controller";
import { action } from "@ember/object";
import { alias, equal } from "@ember/object/computed";
import { inject as service } from "@ember/service";
import DiscourseURL from "discourse/lib/url";
import Category from "discourse/models/category";
export default class DiscoveryController extends Controller {
@service router;
@controller("navigation/category") navigationCategory;
@equal("router.currentRouteName", "discovery.categories")
viewingCategoriesList;
@alias("navigationCategory.category") category;
@alias("navigationCategory.noSubcategories") noSubcategories;
loading = false;
@action
loadingBegan() {
this.set("loading", true);
}
@action
loadingComplete() {
this.set("loading", false);
}
showMoreUrl(period) {
let url = "",
category = this.category;
if (category) {
url = `/c/${Category.slugFor(category)}/${category.id}${
this.noSubcategories ? "/none" : ""
}/l`;
}
url += "/top";
const urlSearchParams = new URLSearchParams();
for (const [key, value] of Object.entries(
this.router.currentRoute.queryParams
)) {
if (typeof value !== "undefined") {
urlSearchParams.set(key, value);
}
}
urlSearchParams.set("period", period);
return `${url}?${urlSearchParams.toString()}`;
}
get showLoadingSpinner() {
return (
this.get("loading") &&
this.siteSettings.page_loading_indicator === "spinner"
);
}
@action
changePeriod(p) {
DiscourseURL.routeTo(this.showMoreUrl(p));
}
}

View File

@ -1,27 +1,12 @@
import { inject as controller } from "@ember/controller"; import Controller from "@ember/controller";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { reads } from "@ember/object/computed"; import { reads } from "@ember/object/computed";
import { dasherize } from "@ember/string"; import { inject as service } from "@ember/service";
import DiscoveryController from "discourse/controllers/discovery";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
const subcategoryStyleComponentNames = { export default class CategoriesController extends Controller {
rows: "categories_only", @service router;
rows_with_featured_topics: "categories_with_featured_topics", @service composer;
boxes: "categories_boxes",
boxes_with_featured_topics: "categories_boxes_with_topics",
};
const mobileCompatibleViews = [
"categories_with_featured_topics",
"subcategories_with_featured_topics",
];
export default class CategoriesController extends DiscoveryController {
@controller discovery;
// this makes sure the composer isn't scoping to a specific category
category = null;
@reads("currentUser.staff") canEdit; @reads("currentUser.staff") canEdit;
@ -30,30 +15,6 @@ export default class CategoriesController extends DiscoveryController {
return this.router.currentRouteName === "discovery.categories"; return this.router.currentRouteName === "discovery.categories";
} }
@discourseComputed("model.parentCategory")
categoryPageStyle(parentCategory) {
let style = this.siteSettings.desktop_category_page_style;
if (this.site.mobileView && !mobileCompatibleViews.includes(style)) {
style = mobileCompatibleViews[0];
}
if (parentCategory) {
style =
subcategoryStyleComponentNames[
parentCategory.get("subcategory_list_style")
] || style;
}
const componentName =
parentCategory &&
(style === "categories_and_latest_topics" ||
style === "categories_and_latest_topics_created_date")
? "categories_only"
: style;
return dasherize(componentName);
}
@action @action
showInserted(event) { showInserted(event) {
event?.preventDefault(); event?.preventDefault();
@ -63,6 +24,13 @@ export default class CategoriesController extends DiscoveryController {
tracker.resetTracking(); tracker.resetTracking();
} }
@action
createTopic() {
this.composer.openNewTopic({
preferDraft: true,
});
}
@action @action
refresh() { refresh() {
this.send("triggerRefresh"); this.send("triggerRefresh");

View File

@ -0,0 +1,143 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import BulkSelectHelper from "discourse/lib/bulk-select-helper";
import { filterTypeForMode } from "discourse/lib/filter-mode";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import { defineTrackedProperty } from "discourse/lib/tracked-tools";
// Just add query params here to have them automatically passed to topic list filters.
export const queryParams = {
order: { replace: true, refreshModel: true },
ascending: { replace: true, refreshModel: true, default: false },
status: { replace: true, refreshModel: true },
state: { replace: true, refreshModel: true },
search: { replace: true, refreshModel: true },
max_posts: { replace: true, refreshModel: true },
min_posts: { replace: true, refreshModel: true },
q: { replace: true, refreshModel: true },
before: { replace: true, refreshModel: true },
bumped_before: { replace: true, refreshModel: true },
f: { replace: true, refreshModel: true },
subset: { replace: true, refreshModel: true },
period: { replace: true, refreshModel: true },
topic_ids: { replace: true, refreshModel: true },
group_name: { replace: true, refreshModel: true },
tags: { replace: true, refreshModel: true },
match_all_tags: { replace: true, refreshModel: true },
no_subcategories: { replace: true, refreshModel: true },
no_tags: { replace: true, refreshModel: true },
exclude_tag: { replace: true, refreshModel: true },
};
export function resetParams(skipParams = []) {
for (const [param, value] of Object.entries(queryParams)) {
if (!skipParams.includes(param)) {
this.controller.set(param, value.default);
}
}
}
export function addDiscoveryQueryParam(p, opts) {
queryParams[p] = opts;
}
@disableImplicitInjections
export default class DiscoveryListController extends Controller {
@service composer;
@service siteSettings;
@service site;
@service currentUser;
@tracked model;
queryParams = Object.keys(queryParams);
bulkSelectHelper = new BulkSelectHelper(this);
constructor() {
super(...arguments);
for (const [name, info] of Object.entries(queryParams)) {
defineTrackedProperty(this, name, info.default);
}
}
get canBulkSelect() {
return (
this.currentUser?.canManageTopic ||
this.showDismissRead ||
this.showResetNew
);
}
get showDismissRead() {
return (
filterTypeForMode(this.model.list?.filter) === "unread" &&
this.model.list.get("topics.length") > 0
);
}
get showResetNew() {
return (
filterTypeForMode(this.model.list?.filter) === "new" &&
this.model.list?.get("topics.length") > 0
);
}
get createTopicTargetCategory() {
const { category } = this.model;
if (category?.canCreateTopic) {
return category;
}
if (this.siteSettings.default_subcategory_on_read_only_category) {
return category?.subcategoryWithCreateTopicPermission;
}
}
get createTopicDisabled() {
// We are in a category route, but user does not have permission for the category
return this.model.category && !this.createTopicTargetCategory;
}
@action
createTopic() {
this.composer.openNewTopic({
category: this.createTopicTargetCategory,
tags: [this.model.tag?.id, ...(this.model.additionalTags ?? [])]
.filter(Boolean)
.reject((t) => ["none", "all"].includes(t))
.join(","),
preferDraft: true,
});
}
@action
changePeriod(p) {
this.period = p;
}
@action
changeSort(sortBy) {
if (sortBy === this.order) {
this.ascending = !this.ascending;
this.model.list.updateSortParams(sortBy, this.ascending);
} else {
this.order = sortBy;
this.ascending = false;
this.model.list.updateSortParams(sortBy, false);
}
}
@action
changeNewListSubset(subset) {
this.subset = subset;
this.model.list.updateNewListSubsetParam(subset);
}
@action
toggleTagInfo() {
this.toggleProperty("showTagInfo");
}
}

View File

@ -1,247 +0,0 @@
import { inject as controller } from "@ember/controller";
import { action } from "@ember/object";
import { alias, empty, equal, gt, or, readOnly } from "@ember/object/computed";
import { inject as service } from "@ember/service";
import DiscoveryController from "discourse/controllers/discovery";
import { routeAction } from "discourse/helpers/route-action";
import BulkSelectHelper from "discourse/lib/bulk-select-helper";
import { endWith } from "discourse/lib/computed";
import { filterTypeForMode } from "discourse/lib/filter-mode";
import { userPath } from "discourse/lib/url";
import DismissTopics from "discourse/mixins/dismiss-topics";
import Topic from "discourse/models/topic";
import deprecated from "discourse-common/lib/deprecated";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
export default class TopicsController extends DiscoveryController.extend(
DismissTopics
) {
@service router;
@service composer;
@controller discovery;
bulkSelectHelper = new BulkSelectHelper(this);
period = null;
expandGloballyPinned = false;
expandAllPinned = false;
@alias("currentUser.id") canStar;
@alias("currentUser.user_option.redirected_to_top.reason") redirectedReason;
@readOnly("model.params.order") order;
@readOnly("model.params.ascending") ascending;
@gt("model.topics.length", 0) hasTopics;
@empty("model.more_topics_url") allLoaded;
@endWith("model.filter", "latest") latest;
@endWith("model.filter", "top") top;
@equal("period", "yearly") yearly;
@equal("period", "quarterly") quarterly;
@equal("period", "monthly") monthly;
@equal("period", "weekly") weekly;
@equal("period", "daily") daily;
@or("currentUser.canManageTopic", "showDismissRead", "showResetNew")
canBulkSelect;
get bulkSelectEnabled() {
return this.bulkSelectHelper.bulkSelectEnabled;
}
get selected() {
return this.bulkSelectHelper.selected;
}
@discourseComputed("model.filter", "model.topics.length")
showDismissRead(filterMode, topicsLength) {
return filterTypeForMode(filterMode) === "unread" && topicsLength > 0;
}
@discourseComputed("model.filter", "model.topics.length")
showResetNew(filterMode, topicsLength) {
return filterTypeForMode(filterMode) === "new" && topicsLength > 0;
}
callResetNew(dismissPosts = false, dismissTopics = false, untrack = false) {
const tracked =
(this.router.currentRoute.queryParams["f"] ||
this.router.currentRoute.queryParams["filter"]) === "tracked";
let topicIds = this.selected
? this.selected.map((topic) => topic.id)
: null;
Topic.resetNew(this.category, !this.noSubcategories, {
tracked,
topicIds,
dismissPosts,
dismissTopics,
untrack,
}).then((result) => {
if (result.topic_ids) {
this.topicTrackingState.removeTopics(result.topic_ids);
}
this.send(
"refresh",
tracked ? { skipResettingParams: ["filter", "f"] } : {}
);
});
}
// Show newly inserted topics
@action
showInserted(event) {
event?.preventDefault();
const tracker = this.topicTrackingState;
// Move inserted into topics
this.model.loadBefore(tracker.get("newIncoming"), true);
tracker.resetTracking();
}
@action
changeSort() {
deprecated(
"changeSort has been changed from an (action) to a (route-action)",
{
since: "2.6.0",
dropFrom: "2.7.0",
id: "discourse.topics.change-sort",
}
);
return routeAction("changeSort", this.router._router, ...arguments)();
}
@action
refresh() {
this.send("triggerRefresh");
}
afterRefresh(filter, list, listModel = list) {
this.setProperties({ model: listModel });
this.resetSelected();
if (this.topicTrackingState) {
this.topicTrackingState.sync(list, filter);
}
this.send("loadingComplete");
}
@discourseComputed("model.filter")
new(filter) {
return filter?.endsWith("new");
}
@discourseComputed("new")
showTopicsAndRepliesToggle(isNew) {
return isNew && this.currentUser?.new_new_view_enabled;
}
@discourseComputed("topicTrackingState.messageCount")
newRepliesCount() {
if (this.currentUser?.new_new_view_enabled) {
return this.topicTrackingState.countUnread({
categoryId: this.category?.id,
noSubcategories: this.noSubcategories,
});
} else {
return 0;
}
}
@discourseComputed("topicTrackingState.messageCount")
newTopicsCount() {
if (this.currentUser?.new_new_view_enabled) {
return this.topicTrackingState.countNew({
categoryId: this.category?.id,
noSubcategories: this.noSubcategories,
});
} else {
return 0;
}
}
@discourseComputed("new")
showTopicPostBadges(isNew) {
return !isNew || this.currentUser?.new_new_view_enabled;
}
@discourseComputed("allLoaded", "model.topics.length")
footerMessage(allLoaded, topicsLength) {
if (!allLoaded) {
return;
}
const category = this.category;
if (category) {
return I18n.t("topics.bottom.category", {
category: category.get("name"),
});
} else {
const split = (this.get("model.filter") || "").split("/");
if (topicsLength === 0) {
return I18n.t("topics.none." + split[0], {
category: split[1],
});
} else {
return I18n.t("topics.bottom." + split[0], {
category: split[1],
});
}
}
}
@discourseComputed("allLoaded", "model.topics.length")
footerEducation(allLoaded, topicsLength) {
if (!allLoaded || topicsLength > 0 || !this.currentUser) {
return;
}
const segments = (this.get("model.filter") || "").split("/");
let tab = segments[segments.length - 1];
if (tab !== "new" && tab !== "unread") {
return;
}
if (tab === "new" && this.currentUser.new_new_view_enabled) {
tab = "new_new";
}
return I18n.t("topics.none.educate." + tab, {
userPrefsUrl: userPath(
`${this.currentUser.get("username_lower")}/preferences/tracking`
),
});
}
get renderNewListHeaderControls() {
return (
this.site.mobileView &&
this.get("showTopicsAndRepliesToggle") &&
!this.get("bulkSelectEnabled")
);
}
@action
toggleBulkSelect() {
this.bulkSelectHelper.toggleBulkSelect();
}
@action
dismissRead(operationType, options) {
this.bulkSelectHelper.dismissRead(operationType, options);
}
@action
updateAutoAddTopicsToBulkSelect(value) {
this.bulkSelectHelper.autoAddTopicsToBulkSelect = value;
}
@action
addTopicsToBulkSelect(topics) {
this.bulkSelectHelper.addTopics(topics);
}
}

View File

@ -1,8 +0,0 @@
import { inject as controller } from "@ember/controller";
import { inject as service } from "@ember/service";
import NavigationDefaultController from "discourse/controllers/navigation/default";
export default class NavigationCategoriesController extends NavigationDefaultController {
@service composer;
@controller("discovery/categories") discoveryCategories;
}

View File

@ -1,40 +0,0 @@
import { tracked } from "@glimmer/tracking";
import { dependentKeyCompat } from "@ember/object/compat";
import { inject as service } from "@ember/service";
import NavigationDefaultController from "discourse/controllers/navigation/default";
import { calculateFilterMode } from "discourse/lib/filter-mode";
export default class NavigationCategoryController extends NavigationDefaultController {
@service composer;
@tracked category;
@tracked filterType;
@tracked noSubcategories;
@dependentKeyCompat
get filterMode() {
return calculateFilterMode({
category: this.category,
filterType: this.filterType,
noSubcategories: this.noSubcategories,
});
}
get createTopicTargetCategory() {
if (this.category?.canCreateTopic) {
return this.category;
}
if (this.siteSettings.default_subcategory_on_read_only_category) {
return this.category?.subcategoryWithCreateTopicPermission;
}
}
get enableCreateTopicButton() {
return !!this.createTopicTargetCategory;
}
get canCreateTopic() {
return this.currentUser?.can_create_topic;
}
}

View File

@ -1,29 +0,0 @@
import { tracked } from "@glimmer/tracking";
import Controller, { inject as controller } from "@ember/controller";
import { dependentKeyCompat } from "@ember/object/compat";
import { inject as service } from "@ember/service";
import { calculateFilterMode } from "discourse/lib/filter-mode";
import { TRACKED_QUERY_PARAM_VALUE } from "discourse/lib/topic-list-tracked-filter";
export default class NavigationDefaultController extends Controller {
@service router;
@service composer;
@controller discovery;
@tracked category;
@tracked filterType;
@tracked noSubcategories;
@dependentKeyCompat
get filterMode() {
return calculateFilterMode({
category: this.category,
filterType: this.filterType,
noSubcategories: this.noSubcategories,
});
}
get skipCategoriesNavItem() {
return this.router.currentRoute.queryParams.f === TRACKED_QUERY_PARAM_VALUE;
}
}

View File

@ -1,293 +0,0 @@
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { dependentKeyCompat } from "@ember/object/compat";
import { or, readOnly } from "@ember/object/computed";
import { inject as service } from "@ember/service";
import DiscoverySortableController from "discourse/controllers/discovery-sortable";
import BulkSelectHelper from "discourse/lib/bulk-select-helper";
import { endWith } from "discourse/lib/computed";
import { calculateFilterMode } from "discourse/lib/filter-mode";
import DismissTopics from "discourse/mixins/dismiss-topics";
import NavItem from "discourse/models/nav-item";
import Topic from "discourse/models/topic";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
export default class TagShowController extends DiscoverySortableController.extend(
DismissTopics
) {
@service dialog;
@service router;
@service currentUser;
@service siteSettings;
@tracked category;
@tracked filterType;
@tracked noSubcategories;
bulkSelectHelper = new BulkSelectHelper(this);
tag = null;
additionalTags = null;
list = null;
@readOnly("currentUser.staff") canAdminTag;
navMode = "latest";
loading = false;
canCreateTopic = false;
showInfo = false;
@endWith("list.filter", "top") top;
@or("currentUser.canManageTopic", "showDismissRead", "showResetNew")
canBulkSelect;
get bulkSelectEnabled() {
return this.bulkSelectHelper.bulkSelectEnabled;
}
get selected() {
return this.bulkSelectHelper.selected;
}
@dependentKeyCompat
get filterMode() {
return calculateFilterMode({
category: this.category,
filterType: this.filterType,
noSubcategories: this.noSubcategories,
});
}
@discourseComputed(
"canCreateTopic",
"category",
"canCreateTopicOnCategory",
"tag",
"canCreateTopicOnTag"
)
createTopicDisabled(
canCreateTopic,
category,
canCreateTopicOnCategory,
tag,
canCreateTopicOnTag
) {
return (
!canCreateTopic ||
(category && !canCreateTopicOnCategory) ||
(tag && !canCreateTopicOnTag)
);
}
@discourseComputed("category", "tag.id", "filterType", "noSubcategories")
navItems(category, tagId, filterType, noSubcategories) {
return NavItem.buildList(category, {
tagId,
filterType,
noSubcategories,
siteSettings: this.siteSettings,
});
}
@discourseComputed("navMode", "list.topics.length", "loading")
footerMessage(navMode, listTopicsLength, loading) {
if (loading) {
return;
}
if (listTopicsLength === 0) {
return I18n.t(`tagging.topics.none.${navMode}`, {
tag: this.tag?.id,
});
} else {
return I18n.t("topics.bottom.tag", {
tag: this.tag?.id,
});
}
}
@discourseComputed("filterType", "list.topics.length")
showDismissRead(filterType, topicsLength) {
return filterType === "unread" && topicsLength > 0;
}
@discourseComputed("filterType")
new(filterType) {
return filterType === "new";
}
@discourseComputed("new")
showTopicsAndRepliesToggle(isNew) {
return isNew && this.currentUser?.new_new_view_enabled;
}
@discourseComputed("topicTrackingState.messageCount")
newRepliesCount() {
if (this.currentUser?.new_new_view_enabled) {
return this.topicTrackingState.countUnread({
categoryId: this.category?.id,
noSubcategories: this.noSubcategories,
tagId: this.tag?.id,
});
} else {
return 0;
}
}
@discourseComputed("topicTrackingState.messageCount")
newTopicsCount() {
if (this.currentUser?.new_new_view_enabled) {
return this.topicTrackingState.countNew({
categoryId: this.category?.id,
noSubcategories: this.noSubcategories,
tagId: this.tag?.id,
});
} else {
return 0;
}
}
@discourseComputed("new", "list.topics.length")
showResetNew(isNew, topicsLength) {
return isNew && topicsLength > 0;
}
callResetNew(dismissPosts = false, dismissTopics = false, untrack = false) {
const filterTracked =
(this.router.currentRoute.queryParams["f"] ||
this.router.currentRoute.queryParams["filter"]) === "tracked";
let topicIds = this.selected ? this.selected.mapBy("id") : null;
Topic.resetNew(this.category, !this.noSubcategories, {
tracked: filterTracked,
tag: this.tag,
topicIds,
dismissPosts,
dismissTopics,
untrack,
}).then((result) => {
if (result.topic_ids) {
this.topicTrackingState.removeTopics(result.topic_ids);
}
this.refresh(
filterTracked ? { skipResettingParams: ["filter", "f"] } : {}
);
});
}
@action
showInserted(event) {
event?.preventDefault();
const tracker = this.topicTrackingState;
this.list.loadBefore(tracker.newIncoming, true);
tracker.resetTracking();
return false;
}
@action
changeSort(order) {
if (order === this.order) {
this.toggleProperty("ascending");
} else {
this.setProperties({ order, ascending: false });
}
}
@action
changeNewListSubset(subset) {
this.set("subset", subset);
}
@action
changePeriod(p) {
this.set("period", p);
}
@action
toggleInfo() {
this.toggleProperty("showInfo");
}
@action
refresh() {
return this.store
.findFiltered("topicList", {
filter: this.list?.filter,
})
.then((list) => {
this.set("list", list);
this.bulkSelectHelper.clear();
});
}
@action
deleteTag(tagInfo) {
const numTopics =
this.get("list.topic_list.tags.firstObject.topic_count") || 0;
let confirmText =
numTopics === 0
? I18n.t("tagging.delete_confirm_no_topics")
: I18n.t("tagging.delete_confirm", { count: numTopics });
if (tagInfo.synonyms.length > 0) {
confirmText +=
" " +
I18n.t("tagging.delete_confirm_synonyms", {
count: tagInfo.synonyms.length,
});
}
this.dialog.deleteConfirm({
message: confirmText,
didConfirm: () => {
return this.tag
.destroyRecord()
.then(() => this.router.transitionTo("tags.index"))
.catch(() => this.dialog.alert(I18n.t("generic_error")));
},
});
}
@action
changeTagNotificationLevel(notificationLevel) {
this.tagNotification
.update({ notification_level: notificationLevel })
.then((response) => {
const payload = response.responseJson;
this.tagNotification.set("notification_level", notificationLevel);
this.currentUser.setProperties({
watched_tags: payload.watched_tags,
watching_first_post_tags: payload.watching_first_post_tags,
tracked_tags: payload.tracked_tags,
muted_tags: payload.muted_tags,
regular_tags: payload.regular_tags,
});
});
}
@action
toggleBulkSelect() {
this.bulkSelectHelper.toggleBulkSelect();
}
@action
dismissRead(operationType, options) {
this.bulkSelectHelper.dismissRead(operationType, options);
}
@action
updateAutoAddTopicsToBulkSelect(value) {
this.bulkSelectHelper.autoAddTopicsToBulkSelect = value;
}
@action
addTopicsToBulkSelect(topics) {
this.bulkSelectHelper.addTopics(topics);
}
}

View File

@ -1,6 +1,7 @@
import { queryParams } from "discourse/controllers/discovery-sortable"; import DiscoveryListController, {
import TagShowController from "discourse/controllers/tag-show"; queryParams,
} from "discourse/controllers/discovery/list";
export default class TagsIntersectionController extends TagShowController { export default class TagsIntersectionController extends DiscoveryListController {
queryParams = [...Object.keys(queryParams), { categoryParam: "category" }]; queryParams = [...Object.keys(queryParams), { categoryParam: "category" }];
} }

View File

@ -27,6 +27,7 @@ export function resetCustomUserNavMessagesDropdownRows() {
export default class extends Controller { export default class extends Controller {
@service router; @service router;
@controller user; @controller user;
@controller userTopicsList;
@tracked group; @tracked group;
@tracked tagId; @tracked tagId;
@ -38,6 +39,10 @@ export default class extends Controller {
@readOnly("router.currentRoute.parent.name") currentParentRouteName; @readOnly("router.currentRoute.parent.name") currentParentRouteName;
@readOnly("site.can_tag_pms") pmTaggingEnabled; @readOnly("site.can_tag_pms") pmTaggingEnabled;
get bulkSelectHelper() {
this.userTopicsList.bulkSelectHelper;
}
get messagesDropdownValue() { get messagesDropdownValue() {
let value; let value;

View File

@ -1,5 +1,4 @@
import { dasherize } from "@ember/string"; import { dasherize } from "@ember/string";
import DiscoverySortableController from "discourse/controllers/discovery-sortable";
import Site from "discourse/models/site"; import Site from "discourse/models/site";
import buildCategoryRoute from "discourse/routes/build-category-route"; import buildCategoryRoute from "discourse/routes/build-category-route";
import buildTopicRoute from "discourse/routes/build-topic-route"; import buildTopicRoute from "discourse/routes/build-topic-route";
@ -9,19 +8,6 @@ export default {
after: "inject-discourse-objects", after: "inject-discourse-objects",
initialize(app) { initialize(app) {
app.register(
"controller:discovery.category",
DiscoverySortableController.extend()
);
app.register(
"controller:discovery.category-none",
DiscoverySortableController.extend()
);
app.register(
"controller:discovery.category-all",
DiscoverySortableController.extend()
);
app.register( app.register(
"route:discovery.category", "route:discovery.category",
buildCategoryRoute({ filter: "default" }) buildCategoryRoute({ filter: "default" })
@ -38,18 +24,6 @@ export default {
const site = Site.current(); const site = Site.current();
site.get("filters").forEach((filter) => { site.get("filters").forEach((filter) => {
const filterDasherized = dasherize(filter); const filterDasherized = dasherize(filter);
app.register(
`controller:discovery.${filterDasherized}`,
DiscoverySortableController.extend()
);
app.register(
`controller:discovery.${filterDasherized}-category`,
DiscoverySortableController.extend()
);
app.register(
`controller:discovery.${filterDasherized}-category-none`,
DiscoverySortableController.extend()
);
app.register( app.register(
`route:discovery.${filterDasherized}`, `route:discovery.${filterDasherized}`,

View File

@ -0,0 +1,105 @@
import EmberObject from "@ember/object";
import { dependentKeyCompat } from "@ember/object/compat";
import { inject as service } from "@ember/service";
import deprecated from "discourse-common/lib/deprecated";
let reopenedClasses = [];
function ControllerShim(resolverName, deprecationId) {
return class AbstractControllerShim extends EmberObject {
static printDeprecation() {
deprecated(
`${resolverName} no longer exists, and this shim will eventually be removed. To fetch information about the current discovery route, use the discovery service instead.`,
{
deprecationId,
}
);
}
static reopen() {
this.printDeprecation();
reopenedClasses.push(resolverName);
return super.reopen(...arguments);
}
@service discovery;
constructor() {
super(...arguments);
this.constructor.printDeprecation();
}
};
}
class NavigationCategoryControllerShim extends ControllerShim(
"controller:navigation/category",
"discourse.navigation-category-controller"
) {
@dependentKeyCompat
get category() {
this.constructor.printDeprecation();
return this.discovery.category;
}
}
class DiscoveryTopicsControllerShim extends ControllerShim(
"controller:discovery/topics",
"discourse.discovery-topics-controller"
) {
@dependentKeyCompat
get model() {
this.constructor.printDeprecation();
if (this.discovery.onDiscoveryRoute) {
return this.discovery.currentTopicList;
}
}
@dependentKeyCompat
get category() {
this.constructor.printDeprecation();
if (this.discovery.onDiscoveryRoute) {
return this.discovery.category;
}
}
}
class TagShowControllerShim extends ControllerShim(
"controller:tag-show",
"discourse.tag-show-controller"
) {
@dependentKeyCompat
get tag() {
this.constructor.printDeprecation();
return this.discovery.tag;
}
}
export default {
initialize(container) {
container.register(
"controller:navigation/category",
NavigationCategoryControllerShim
);
container.register(
"controller:discovery/topics",
DiscoveryTopicsControllerShim
);
container.register("controller:tag-show", TagShowControllerShim);
container.lookup("service:router").on("routeDidChange", (transition) => {
const destination = transition.to?.name;
if (
destination?.startsWith("discovery.") ||
destination?.startsWith("tags.show") ||
destination === "tag.show"
) {
// Ensure any reopened shims are initialized in case anything has added observers
reopenedClasses.forEach((resolverName) =>
container.lookup(resolverName)
);
}
});
},
};

View File

@ -4,6 +4,7 @@ import { inject as service } from "@ember/service";
import { TrackedArray } from "@ember-compat/tracked-built-ins"; import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { NotificationLevels } from "discourse/lib/notification-levels"; import { NotificationLevels } from "discourse/lib/notification-levels";
import Topic from "discourse/models/topic"; import Topic from "discourse/models/topic";
import { bind } from "discourse-common/utils/decorators";
export default class BulkSelectHelper { export default class BulkSelectHelper {
@service router; @service router;
@ -28,6 +29,7 @@ export default class BulkSelectHelper {
this.selected.concat(topics); this.selected.concat(topics);
} }
@bind
toggleBulkSelect() { toggleBulkSelect() {
this.bulkSelectEnabled = !this.bulkSelectEnabled; this.bulkSelectEnabled = !this.bulkSelectEnabled;
this.clear(); this.clear();

View File

@ -21,7 +21,7 @@ import { addSearchSuggestion as addGlimmerSearchSuggestion } from "discourse/com
import { REFRESH_COUNTS_APP_EVENT_NAME as REFRESH_USER_SIDEBAR_CATEGORIES_SECTION_COUNTS_APP_EVENT_NAME } from "discourse/components/sidebar/user/categories-section"; import { REFRESH_COUNTS_APP_EVENT_NAME as REFRESH_USER_SIDEBAR_CATEGORIES_SECTION_COUNTS_APP_EVENT_NAME } from "discourse/components/sidebar/user/categories-section";
import { addTopicTitleDecorator } from "discourse/components/topic-title"; import { addTopicTitleDecorator } from "discourse/components/topic-title";
import { addUserMenuProfileTabItem } from "discourse/components/user-menu/profile-tab-content"; import { addUserMenuProfileTabItem } from "discourse/components/user-menu/profile-tab-content";
import { addDiscoveryQueryParam } from "discourse/controllers/discovery-sortable"; import { addDiscoveryQueryParam } from "discourse/controllers/discovery/list";
import { registerFullPageSearchType } from "discourse/controllers/full-page-search"; import { registerFullPageSearchType } from "discourse/controllers/full-page-search";
import { registerCustomPostMessageCallback as registerCustomPostMessageCallback1 } from "discourse/controllers/topic"; import { registerCustomPostMessageCallback as registerCustomPostMessageCallback1 } from "discourse/controllers/topic";
import { registerCustomUserNavMessagesDropdownRow } from "discourse/controllers/user-private-messages"; import { registerCustomUserNavMessagesDropdownRow } from "discourse/controllers/user-private-messages";

View File

@ -0,0 +1,76 @@
import { tracked } from "@glimmer/tracking";
/**
* Define a tracked property on an object without needing to use the @tracked decorator.
* Useful when meta-programming the creation of properties on an object.
*
* This must be run before the property is first accessed, so it normally makes sense for
* this to only be called from a constructor.
*/
export function defineTrackedProperty(target, key, value) {
Object.defineProperty(
target,
key,
tracked(target, key, { enumerable: true, value })
);
}
class ResettableTrackedState {
@tracked currentValue;
previousUpstreamValue;
}
function getOrCreateState(map, instance) {
let state = map.get(instance);
if (!state) {
state = new ResettableTrackedState();
map.set(instance, state);
}
return state;
}
/**
* @decorator
*
* Marks a field as tracked. Its initializer will be re-run whenever upstream state changes.
*
* @example
*
* ```js
* class UserRenameForm {
* @resettableTracked fullName = this.args.fullName;
*
* updateName(newName) {
* this.fullName = newName;
* }
* }
* ```
*
* `this.fullName` will be updated whenever `updateName()` is called, or there is a change to
* `this.args.fullName`.
*
*/
export function resettableTracked(prototype, key, descriptor) {
// One WeakMap per-property-per-class. Keys are instances of the class
const states = new WeakMap();
return {
get() {
const state = getOrCreateState(states, this);
const upstreamValue = descriptor.initializer?.call(this);
if (upstreamValue !== state.previousUpstreamValue) {
state.currentValue = upstreamValue;
state.previousUpstreamValue = upstreamValue;
}
return state.currentValue;
},
set(value) {
const state = getOrCreateState(states, this);
state.currentValue = value;
},
};
}

View File

@ -260,7 +260,7 @@ const DiscourseURL = EmberObject.extend({
if (oldPath === path) { if (oldPath === path) {
// If navigating to the same path send an app event. // If navigating to the same path send an app event.
// Views can watch it and tell their controllers to refresh // Views can watch it and tell their controllers to refresh
this.appEvents.trigger("url:refresh"); this.routerService.refresh();
} }
// TODO: Extract into rules we can inject into the URL handler // TODO: Extract into rules we can inject into the URL handler
@ -400,7 +400,7 @@ const DiscourseURL = EmberObject.extend({
(path === "/" || path === "/" + homepage) && (path === "/" || path === "/" + homepage) &&
(oldPath === "/" || oldPath === "/" + homepage) (oldPath === "/" || oldPath === "/" + homepage)
) { ) {
this.appEvents.trigger("url:refresh"); this.routerService.refresh();
return true; return true;
} }
@ -421,6 +421,10 @@ const DiscourseURL = EmberObject.extend({
return this.container.lookup("router:main"); return this.container.lookup("router:main");
}, },
get routerService() {
return this.container.lookup("service:router");
},
get appEvents() { get appEvents() {
return this.container.lookup("service:app-events"); return this.container.lookup("service:app-events");
}, },

View File

@ -1,26 +0,0 @@
import { action } from "@ember/object";
import Mixin from "@ember/object/mixin";
import { inject as service } from "@ember/service";
import DismissNew from "discourse/components/modal/dismiss-new";
export default Mixin.create({
modal: service(),
currentUser: service(),
@action
resetNew() {
if (!this.currentUser.new_new_view_enabled) {
return this.callResetNew();
}
this.modal.show(DismissNew, {
model: {
selectedTopics: this.selected,
subset: this.model.listParams?.subset,
dismissCallback: ({ dismissPosts, dismissTopics, untrack }) => {
this.callResetNew(dismissPosts, dismissTopics, untrack);
},
},
});
},
});

View File

@ -1,18 +0,0 @@
// A Mixin that a view can use to listen for 'url:refresh' when
// it is on screen, and will send an action to refresh its data.
//
// This is useful if you want to get around Ember's default
// behavior of not refreshing when navigating to the same place.
export default {
didInsertElement() {
this._super(...arguments);
this.appEvents.on("url:refresh", this, "refresh");
},
willDestroyElement() {
this._super(...arguments);
this.appEvents.off("url:refresh", this, "refresh");
},
};

View File

@ -1,4 +1,5 @@
import ArrayProxy from "@ember/array/proxy"; import ArrayProxy from "@ember/array/proxy";
import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { number } from "discourse/lib/formatter"; import { number } from "discourse/lib/formatter";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
@ -103,7 +104,7 @@ CategoryList.reopenClass({
return ajax( return ajax(
`/categories.json?parent_category_id=${category.get("id")}` `/categories.json?parent_category_id=${category.get("id")}`
).then((result) => { ).then((result) => {
return CategoryList.create({ return EmberObject.create({
categories: this.categoriesFrom(store, result), categories: this.categoriesFrom(store, result),
parentCategory: category, parentCategory: category,
}); });

View File

@ -791,7 +791,7 @@ const Composer = RestModel.extend({
composerTotalOpened: opts.composerTime, composerTotalOpened: opts.composerTime,
typingTime: opts.typingTime, typingTime: opts.typingTime,
whisper: opts.whisper, whisper: opts.whisper,
tags: opts.tags, tags: opts.tags || [],
noBump: opts.noBump, noBump: opts.noBump,
}); });

View File

@ -1,13 +1,9 @@
import { action } from "@ember/object"; import { action } from "@ember/object";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { all, Promise } from "rsvp"; import { queryParams, resetParams } from "discourse/controllers/discovery/list";
import { import { disableImplicitInjections } from "discourse/lib/implicit-injections";
changeNewListSubset,
changeSort,
queryParams,
resetParams,
} from "discourse/controllers/discovery-sortable";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
import { setTopicList } from "discourse/lib/topic-list-tracker";
import Category from "discourse/models/category"; import Category from "discourse/models/category";
import CategoryList from "discourse/models/category-list"; import CategoryList from "discourse/models/category-list";
import TopicList from "discourse/models/topic-list"; import TopicList from "discourse/models/topic-list";
@ -18,64 +14,62 @@ import {
import DiscourseRoute from "discourse/routes/discourse"; import DiscourseRoute from "discourse/routes/discourse";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
@disableImplicitInjections
class AbstractCategoryRoute extends DiscourseRoute { class AbstractCategoryRoute extends DiscourseRoute {
@service composer; @service composer;
@service router; @service router;
@service store;
@service topicTrackingState;
@service("search") searchService;
queryParams = queryParams; queryParams = queryParams;
model(modelParams) { templateName = "discovery/list";
controllerName = "discovery/list";
async model(params, transition) {
const category = Category.findBySlugPathWithID( const category = Category.findBySlugPathWithID(
modelParams.category_slug_path_with_id params.category_slug_path_with_id
); );
if (!category) { if (!category) {
const parts = modelParams.category_slug_path_with_id.split("/");
if (parts.length > 0 && parts[parts.length - 1].match(/^\d+$/)) {
parts.pop();
}
return Category.reloadBySlugPath(parts.join("/")).then((result) => {
const record = this.store.createRecord("category", result.category);
record.setupGroupsAndPermissions();
this.site.updateCategory(record);
return { category: record, modelParams };
});
}
if (category) {
return { category, modelParams };
}
}
afterModel(model, transition) {
if (!model) {
this.router.replaceWith("/404"); this.router.replaceWith("/404");
return; return;
} }
const { category, modelParams } = model;
if ( if (
this.routeConfig?.no_subcategories === undefined && this.routeConfig?.no_subcategories === undefined &&
category.default_list_filter === "none" && category.default_list_filter === "none" &&
this.routeConfig?.filter === "default" && this.routeConfig?.filter === "default" &&
modelParams params
) { ) {
// TODO: avoid throwing away preload data by redirecting on the server // TODO: avoid throwing away preload data by redirecting on the server
PreloadStore.getAndRemove("topic_list"); PreloadStore.getAndRemove("topic_list");
this.router.replaceWith( this.router.replaceWith(
"discovery.categoryNone", "discovery.categoryNone",
modelParams.category_slug_path_with_id params.category_slug_path_with_id
); );
return; return;
} }
this._setupNavigation(category); const subcategoryListPromise = this._createSubcategoryList(category);
return all([ const topicListPromise = this._retrieveTopicList(
this._createSubcategoryList(category), category,
this._retrieveTopicList(category, transition, modelParams), transition,
]); params
);
const noSubcategories = !!this.routeConfig?.no_subcategories;
const filterType = this.filter(category).split("/")[0];
return {
category,
modelParams: params,
subcategoryList: await subcategoryListPromise,
list: await topicListPromise,
noSubcategories,
filterType,
};
} }
filter(category) { filter(category) {
@ -84,32 +78,13 @@ class AbstractCategoryRoute extends DiscourseRoute {
: this.routeConfig?.filter; : this.routeConfig?.filter;
} }
_setupNavigation(category) { async _createSubcategoryList(category) {
const noSubcategories =
this.routeConfig && !!this.routeConfig.no_subcategories,
filterType = this.filter(category).split("/")[0];
this.controllerFor("navigation/category").setProperties({
category,
filterType,
noSubcategories,
});
}
_createSubcategoryList(category) {
this._categoryList = null;
if (category.isParent && category.show_subcategory_list) { if (category.isParent && category.show_subcategory_list) {
return CategoryList.listForParent(this.store, category).then( return CategoryList.listForParent(this.store, category);
(list) => (this._categoryList = list) }
);
} }
// If we're not loading a subcategory list just resolve async _retrieveTopicList(category, transition, modelParams) {
return Promise.resolve();
}
_retrieveTopicList(category, transition, modelParams) {
const findOpts = filterQueryParams(modelParams, this.routeConfig); const findOpts = filterQueryParams(modelParams, this.routeConfig);
const extras = { cached: this.isPoppedState(transition) }; const extras = { cached: this.isPoppedState(transition) };
@ -119,17 +94,16 @@ class AbstractCategoryRoute extends DiscourseRoute {
} }
listFilter += `/l/${this.filter(category)}`; listFilter += `/l/${this.filter(category)}`;
return findTopicList( const topicList = await findTopicList(
this.store, this.store,
this.topicTrackingState, this.topicTrackingState,
listFilter, listFilter,
findOpts, findOpts,
extras extras
).then((list) => { );
TopicList.hideUniformCategory(list, category); TopicList.hideUniformCategory(topicList, category);
this.set("topics", list);
return list; return topicList;
});
} }
titleToken() { titleToken() {
@ -153,52 +127,20 @@ class AbstractCategoryRoute extends DiscourseRoute {
} }
setupController(controller, model) { setupController(controller, model) {
const topics = this.topics, super.setupController(...arguments);
category = model.category; controller.bulkSelectHelper.clear();
this.searchService.searchContext = model.category.get("searchContext");
setTopicList(model.list);
let topicOpts = { const p = model.category.params;
model: topics, if (p?.order !== undefined) {
category, controller.order = p.order;
period:
topics.get("for_period") ||
(model.modelParams && model.modelParams.period),
noSubcategories: this.routeConfig && !!this.routeConfig.no_subcategories,
expandAllPinned: true,
};
const p = category.get("params");
if (p && Object.keys(p).length) {
if (p.order !== undefined) {
topicOpts.order = p.order;
} }
if (p.ascending !== undefined) { if (p?.ascending !== undefined) {
topicOpts.ascending = p.ascending; controller.ascending = p.ascending;
} }
} }
this.controllerFor("discovery/topics").setProperties(topicOpts);
this.controllerFor("discovery/topics").bulkSelectHelper.clear();
this.searchService.searchContext = category.get("searchContext");
this.set("topics", null);
}
renderTemplate() {
this.render("navigation/category", { outlet: "navigation-bar" });
if (this._categoryList) {
this.render("discovery/categories", {
outlet: "header-list-container",
model: this._categoryList,
});
} else {
this.disconnectOutlet({ outlet: "header-list-container" });
}
this.render("discovery/topics", {
controller: "discovery/topics",
outlet: "list-container",
});
}
deactivate() { deactivate() {
super.deactivate(...arguments); super.deactivate(...arguments);
@ -216,16 +158,6 @@ class AbstractCategoryRoute extends DiscourseRoute {
this.refresh(); this.refresh();
} }
@action
changeSort(sortBy) {
changeSort.call(this, sortBy);
}
@action
changeNewListSubset(subset) {
changeNewListSubset.call(this, subset);
}
@action @action
resetParams(skipParams = []) { resetParams(skipParams = []) {
resetParams.call(this, skipParams); resetParams.call(this, skipParams);

View File

@ -1,16 +1,12 @@
import { action } from "@ember/object"; import { action } from "@ember/object";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { import { queryParams, resetParams } from "discourse/controllers/discovery/list";
changeNewListSubset, import { disableImplicitInjections } from "discourse/lib/implicit-injections";
changeSort, import { setTopicList } from "discourse/lib/topic-list-tracker";
queryParams,
resetParams,
} from "discourse/controllers/discovery-sortable";
import { defaultHomepage } from "discourse/lib/utilities"; import { defaultHomepage } from "discourse/lib/utilities";
import Session from "discourse/models/session"; import Session from "discourse/models/session";
import Site from "discourse/models/site"; import Site from "discourse/models/site";
import User from "discourse/models/user";
import DiscourseRoute from "discourse/routes/discourse"; import DiscourseRoute from "discourse/routes/discourse";
import { deepEqual } from "discourse-common/lib/object"; import { deepEqual } from "discourse-common/lib/object";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
@ -96,31 +92,36 @@ export async function findTopicList(
return list; return list;
} }
@disableImplicitInjections
class AbstractTopicRoute extends DiscourseRoute { class AbstractTopicRoute extends DiscourseRoute {
@service screenTrack; @service screenTrack;
@service store;
@service topicTrackingState;
@service currentUser;
queryParams = queryParams; queryParams = queryParams;
templateName = "discovery/list";
controllerName = "discovery/list";
beforeModel() { async model(data, transition) {
this.controllerFor("navigation/default").set(
"filterType",
this.routeConfig.filter.split("/")[0]
);
}
model(data, transition) {
// attempt to stop early cause we need this to be called before .sync // attempt to stop early cause we need this to be called before .sync
this.screenTrack.stop(); this.screenTrack.stop();
const findOpts = filterQueryParams(data), const findOpts = filterQueryParams(data),
findExtras = { cached: this.isPoppedState(transition) }; findExtras = { cached: this.isPoppedState(transition) };
return findTopicList( const topicListPromise = findTopicList(
this.store, this.store,
this.topicTrackingState, this.topicTrackingState,
this.routeConfig.filter, this.routeConfig.filter,
findOpts, findOpts,
findExtras findExtras
); );
return {
list: await topicListPromise,
filterType: this.routeConfig.filter.split("/")[0],
};
} }
titleToken() { titleToken() {
@ -135,40 +136,9 @@ class AbstractTopicRoute extends DiscourseRoute {
} }
setupController(controller, model) { setupController(controller, model) {
const topicOpts = { super.setupController(...arguments);
model, controller.bulkSelectHelper.clear();
category: null, setTopicList(model.list);
period: model.get("for_period") || model.get("params.period"),
expandAllPinned: false,
expandGloballyPinned: true,
};
this.controllerFor("discovery/topics").setProperties(topicOpts);
this.controllerFor("discovery/topics").bulkSelectHelper.clear();
this.controllerFor("navigation/default").set(
"canCreateTopic",
model.get("can_create_topic")
);
}
renderTemplate() {
this.render("navigation/default", { outlet: "navigation-bar" });
this.render("discovery/topics", {
controller: "discovery/topics",
outlet: "list-container",
});
}
@action
changeSort(sortBy) {
changeSort.call(this, sortBy);
}
@action
changeNewListSubset(subset) {
changeNewListSubset.call(this, subset);
} }
@action @action
@ -178,10 +148,10 @@ class AbstractTopicRoute extends DiscourseRoute {
@action @action
willTransition() { willTransition() {
if (this.routeConfig.filter === "top") { if (this.routeConfig.filter === "top" && this.currentUser) {
User.currentProp("user_option.should_be_redirected_to_top", false); this.currentUser.set("user_option.should_be_redirected_to_top", false);
if (User.currentProp("user_option.redirected_to_top")) { if (this.currentUser.user_option?.redirected_to_top) {
User.currentProp("user_option.redirected_to_top.reason", null); this.currentUser.set("user_option.redirected_to_top.reason", null);
} }
} }
return super.willTransition(...arguments); return super.willTransition(...arguments);

View File

@ -3,7 +3,6 @@ import { inject as service } from "@ember/service";
import { hash } from "rsvp"; import { hash } from "rsvp";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
import showModal from "discourse/lib/show-modal";
import { defaultHomepage } from "discourse/lib/utilities"; import { defaultHomepage } from "discourse/lib/utilities";
import CategoryList from "discourse/models/category-list"; import CategoryList from "discourse/models/category-list";
import TopicList from "discourse/models/topic-list"; import TopicList from "discourse/models/topic-list";
@ -14,10 +13,8 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
@service router; @service router;
@service session; @service session;
renderTemplate() { templateName = "discovery/categories";
this.render("navigation/categories", { outlet: "navigation-bar" }); controllerName = "discovery/categories";
this.render("discovery/categories", { outlet: "list-container" });
}
findCategories() { findCategories() {
let style = let style =
@ -131,27 +128,16 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
return I18n.t("filters.categories.title"); return I18n.t("filters.categories.title");
} }
setupController(controller, model) { setupController(controller) {
controller.set("model", model); controller.setProperties({
discovery: this.controllerFor("discovery"),
this.controllerFor("navigation/categories").setProperties({
showCategoryAdmin: model.get("can_create_category"),
canCreateTopic: model.get("can_create_topic"),
}); });
super.setupController(...arguments);
} }
@action @action
triggerRefresh() { triggerRefresh() {
this.refresh(); this.refresh();
} }
@action
createCategory() {
this.router.transitionTo("newCategory");
}
@action
reorderCategories() {
showModal("reorder-categories");
}
} }

View File

@ -1,4 +1,3 @@
import { action } from "@ember/object";
import DiscourseRoute from "discourse/routes/discourse"; import DiscourseRoute from "discourse/routes/discourse";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
@ -18,29 +17,4 @@ export default class DiscoveryFilterRoute extends DiscourseRoute {
const filterText = I18n.t("filters.filter.title"); const filterText = I18n.t("filters.filter.title");
return I18n.t("filters.with_topics", { filter: filterText }); return I18n.t("filters.with_topics", { filter: filterText });
} }
setupController(_controller, model) {
this.controllerFor("discovery/topics").setProperties({ model });
this.controllerFor("navigation/filter").setProperties({
newQueryString: this.paramsFor("discovery.filter").q,
});
}
renderTemplate() {
this.render("navigation/filter", { outlet: "navigation-bar" });
this.render("discovery/topics", {
controller: "discovery/topics",
outlet: "list-container",
});
}
// TODO(tgxworld): The following 2 actions are required by the `discovery/topics` controller which is not necessary for this route.
// Figure out a way to remove this.
@action
changeSort() {}
@action
changeNewListSubset() {}
} }

View File

@ -1,13 +1,11 @@
import { action } from "@ember/object"; import { action } from "@ember/object";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { resetCachedTopicList } from "discourse/lib/cached-topic-list"; import { resetCachedTopicList } from "discourse/lib/cached-topic-list";
import { setTopicList } from "discourse/lib/topic-list-tracker";
import User from "discourse/models/user"; import User from "discourse/models/user";
import DiscourseRoute from "discourse/routes/discourse"; import DiscourseRoute from "discourse/routes/discourse";
/** /**
The parent route for all discovery routes. The parent route for all discovery routes.
Handles the logic for showing the loading spinners.
**/ **/
export default class DiscoveryRoute extends DiscourseRoute { export default class DiscoveryRoute extends DiscourseRoute {
@service router; @service router;
@ -47,48 +45,12 @@ export default class DiscoveryRoute extends DiscourseRoute {
} }
} }
@action
loading() {
this.controllerFor("discovery").loadingBegan();
// We don't want loading to bubble
return true;
}
@action
loadingComplete() {
this.controllerFor("discovery").loadingComplete();
}
@action
didTransition() {
this.send("loadingComplete");
const model = this.controllerFor("discovery/topics").get("model");
setTopicList(model);
}
// clear a pinned topic // clear a pinned topic
@action @action
clearPin(topic) { clearPin(topic) {
topic.clearPin(); topic.clearPin();
} }
@action
dismissReadTopics(dismissTopics) {
const operationType = dismissTopics ? "topics" : "posts";
this.send("dismissRead", operationType);
}
@action
dismissRead(operationType) {
const controller = this.controllerFor("discovery/topics");
controller.send("dismissRead", operationType, {
categoryId: controller.get("category.id"),
includeSubcategories: !controller.noSubcategories,
});
}
refresh() { refresh() {
resetCachedTopicList(this.session); resetCachedTopicList(this.session);
super.refresh(); super.refresh();

View File

@ -1,35 +1,35 @@
import { action } from "@ember/object"; import { action } from "@ember/object";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { import { queryParams, resetParams } from "discourse/controllers/discovery/list";
queryParams, import { filterTypeForMode } from "discourse/lib/filter-mode";
resetParams, import { disableImplicitInjections } from "discourse/lib/implicit-injections";
} from "discourse/controllers/discovery-sortable";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
import showModal from "discourse/lib/show-modal";
import { setTopicList } from "discourse/lib/topic-list-tracker"; import { setTopicList } from "discourse/lib/topic-list-tracker";
import { escapeExpression } from "discourse/lib/utilities"; import { escapeExpression } from "discourse/lib/utilities";
import Category from "discourse/models/category"; import Category from "discourse/models/category";
import Composer from "discourse/models/composer";
import PermissionType from "discourse/models/permission-type"; import PermissionType from "discourse/models/permission-type";
import { import {
filterQueryParams, filterQueryParams,
findTopicList, findTopicList,
} from "discourse/routes/build-topic-route"; } from "discourse/routes/build-topic-route";
import DiscourseRoute from "discourse/routes/discourse"; import DiscourseRoute from "discourse/routes/discourse";
import { makeArray } from "discourse-common/lib/helpers";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
const NONE = "none"; const NONE = "none";
const ALL = "all"; const ALL = "all";
@disableImplicitInjections
export default class TagShowRoute extends DiscourseRoute { export default class TagShowRoute extends DiscourseRoute {
@service composer; @service composer;
@service router; @service router;
@service currentUser; @service currentUser;
@service store;
@service topicTrackingState;
@service("search") searchService;
queryParams = queryParams; queryParams = queryParams;
controllerName = "tag.show"; controllerName = "discovery/list";
templateName = "tag.show"; templateName = "discovery/list";
routeConfig = {}; routeConfig = {};
get navMode() { get navMode() {
@ -40,14 +40,6 @@ export default class TagShowRoute extends DiscourseRoute {
return this.routeConfig.noSubcategories; return this.routeConfig.noSubcategories;
} }
beforeModel() {
const controller = this.controllerFor(this.controllerName);
controller.setProperties({
loading: true,
showInfo: false,
});
}
async model(params, transition) { async model(params, transition) {
const tag = this.store.createRecord("tag", { const tag = this.store.createRecord("tag", {
id: escapeExpression(params.tag_id), id: escapeExpression(params.tag_id),
@ -63,7 +55,7 @@ export default class TagShowRoute extends DiscourseRoute {
}); });
} }
const filterType = this.navMode.split("/")[0]; const filterType = filterTypeForMode(this.navMode);
let tagNotification; let tagNotification;
if (tag && tag.id !== NONE && this.currentUser && !additionalTags) { if (tag && tag.id !== NONE && this.currentUser && !additionalTags) {
@ -139,8 +131,6 @@ export default class TagShowRoute extends DiscourseRoute {
}); });
} }
setTopicList(list);
return { return {
tag, tag,
category, category,
@ -151,20 +141,14 @@ export default class TagShowRoute extends DiscourseRoute {
canCreateTopic: list.can_create_topic, canCreateTopic: list.can_create_topic,
canCreateTopicOnCategory: category?.permission === PermissionType.FULL, canCreateTopicOnCategory: category?.permission === PermissionType.FULL,
canCreateTopicOnTag: !tag.staff || this.currentUser?.staff, canCreateTopicOnTag: !tag.staff || this.currentUser?.staff,
noSubcategories: this.noSubcategories,
}; };
} }
setupController(controller, model) { setupController(controller, model) {
const noSubcategories = this.noSubcategories; super.setupController(...arguments);
controller.bulkSelectHelper.clear();
controller.setProperties({ setTopicList(model.list);
model: model.tag,
...model,
period: model.list.for_period,
navMode: this.navMode,
noSubcategories,
loading: false,
});
if (model.category || model.additionalTags) { if (model.category || model.additionalTags) {
const tagIntersectionSearchContext = { const tagIntersectionSearchContext = {
@ -220,68 +204,10 @@ export default class TagShowRoute extends DiscourseRoute {
this.searchService.searchContext = null; this.searchService.searchContext = null;
} }
@action
renameTag(tag) {
showModal("rename-tag", { model: tag });
}
@action
createTopic() {
if (this.currentUser?.has_topic_draft) {
this.openTopicDraft();
} else {
const controller = this.controllerFor(this.controllerName);
this.composer
.open({
categoryId: controller.category?.id,
action: Composer.CREATE_TOPIC,
draftKey: Composer.NEW_TOPIC_KEY,
})
.then(() => {
// Pre-fill the tags input field
if (this.composer.canEditTags && controller.tag?.id) {
const composerModel = this.composer.model;
composerModel.set("tags", this._controllerTags(controller));
}
});
}
}
@action
dismissReadTopics(dismissTopics) {
const operationType = dismissTopics ? "topics" : "posts";
this.send("dismissRead", operationType);
}
@action
dismissRead(operationType) {
const controller = this.controllerFor(this.controllerName);
let options = {
tagName: controller.tag?.id,
};
const categoryId = controller.category?.id;
if (categoryId) {
options = {
...options,
categoryId,
includeSubcategories: !controller.noSubcategories,
};
}
controller.send("dismissRead", operationType, options);
}
@action @action
resetParams(skipParams = []) { resetParams(skipParams = []) {
resetParams.call(this, skipParams); resetParams.call(this, skipParams);
} }
_controllerTags(controller) {
return [controller.get("model.id"), ...makeArray(controller.additionalTags)]
.filter(Boolean)
.filter((tag) => ![NONE, ALL].includes(tag));
}
} }
export function buildTagRoute(routeConfig = {}) { export function buildTagRoute(routeConfig = {}) {

View File

@ -6,5 +6,5 @@ import { buildTagRoute } from "discourse/routes/tag-show";
// be handled by the intersection logic. Defining tags-intersection as something separate avoids // be handled by the intersection logic. Defining tags-intersection as something separate avoids
// that confusion. // that confusion.
export default class extends buildTagRoute() { export default class extends buildTagRoute() {
controllerName = "tags.intersection"; controllerName = "tags-intersection";
} }

View File

@ -0,0 +1,43 @@
import Service, { inject as service } from "@ember/service";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
/**
* The discovery service acts as a 'public API' for our discovery
* routes. Themes/plugins can use this service as a stable way
* to learn information about the current route.
*/
@disableImplicitInjections
export default class DiscoveryService extends Service {
@service router;
get onDiscoveryRoute() {
const { currentRouteName } = this.router;
return (
currentRouteName?.startsWith("discovery.") ||
currentRouteName?.startsWith("tags.show") ||
currentRouteName === "tag.show"
);
}
get category() {
if (this.onDiscoveryRoute) {
return this.#routeAttrs.category;
}
}
get tag() {
if (this.onDiscoveryRoute) {
return this.#routeAttrs.tag;
}
}
get currentTopicList() {
if (this.onDiscoveryRoute) {
return this.#routeAttrs.list;
}
}
get #routeAttrs() {
return this.router.currentRoute.attributes;
}
}

View File

@ -1,4 +1,14 @@
<PluginOutlet <Discovery::Layout>
<:navigation>
<Discovery::Navigation
@showCategoryAdmin={{this.model.can_create_category}}
@canCreateTopic={{this.model.can_create_topic}}
@createTopic={{this.createTopic}}
@filterType="categories"
/>
</:navigation>
<:list>
<PluginOutlet
@name="above-discovery-categories" @name="above-discovery-categories"
@connectorTagName="div" @connectorTagName="div"
@outletArgs={{hash @outletArgs={{hash
@ -6,11 +16,15 @@
categoryPageStyle=this.categoryPageStyle categoryPageStyle=this.categoryPageStyle
topics=this.model.topics topics=this.model.topics
}} }}
/> />
<DiscoveryCategories @refresh={{action "refresh"}}> {{body-class "categories-list"}}
<div class="contents">
{{#if (and this.topicTrackingState.hasIncoming this.isCategoriesRoute)}} {{#if (and this.topicTrackingState.hasIncoming this.isCategoriesRoute)}}
<div class="show-more {{if this.hasTopics 'has-topics'}}"> <div
class={{concat-class "show-more" (if this.hasTopics "has-topics")}}
>
<div <div
role="button" role="button"
class="alert alert-info clickable" class="alert alert-info clickable"
@ -25,24 +39,14 @@
</div> </div>
{{/if}} {{/if}}
{{#if <Discovery::CategoriesDisplay
(eq this.categoryPageStyle "categories-and-latest-topics-created-date")
}}
<CategoriesAndLatestTopics
@categories={{this.model.categories}} @categories={{this.model.categories}}
@topics={{this.model.topics}} @topics={{this.model.topics}}
@parentCategory={{this.model.parentCategory}}
/> />
{{else}} </div>
{{component
this.categoryPageStyle
categories=this.model.categories
topics=this.model.topics
}}
{{/if}}
</DiscoveryCategories> <PluginOutlet
<PluginOutlet
@name="below-discovery-categories" @name="below-discovery-categories"
@connectorTagName="div" @connectorTagName="div"
@outletArgs={{hash @outletArgs={{hash
@ -50,4 +54,6 @@
categoryPageStyle=this.categoryPageStyle categoryPageStyle=this.categoryPageStyle
topics=this.model.topics topics=this.model.topics
}} }}
/> />
</:list>
</Discovery::Layout>

View File

@ -0,0 +1,18 @@
<Discovery::Layout>
<:navigation>
<Discovery::FilterNavigation
@queryString={{this.q}}
@updateTopicsListQueryParams={{this.updateTopicsListQueryParams}}
/>
</:navigation>
<:list>
<Discovery::Topics
@period={{this.period}}
@expandAllPinned={{this.expandAllPinned}}
@expandAllGloballyPinned={{this.expandAllGloballyPinned}}
@model={{this.model}}
@canBulkSelect={{this.canBulkSelect}}
@bulkSelectHelper={{this.bulkSelectHelper}}
/>
</:list>
</Discovery::Layout>

View File

@ -0,0 +1,49 @@
<Discovery::Layout
@category={{this.model.category}}
@createTopicDisabled={{this.createTopicDisabled}}
>
<:navigation>
<Discovery::Navigation
@category={{this.model.category}}
@tag={{this.model.tag}}
@additionalTags={{this.model.additionalTags}}
@filterType={{this.model.filterType}}
@noSubcategories={{this.model.noSubcategories}}
@canBulkSelect={{this.canBulkSelect}}
@bulkSelectHelper={{this.bulkSelectHelper}}
@createTopic={{this.createTopic}}
@createTopicDisabled={{this.createTopicDisabled}}
@canCreateTopicOnTag={{this.model.canCreateTopicOnTag}}
@toggleTagInfo={{this.toggleTagInfo}}
@tagNotification={{this.model.tagNotification}}
/>
</:navigation>
<:header>
{{#if this.model.subcategoryList}}
<Discovery::CategoriesDisplay
@categories={{this.model.subcategoryList.categories}}
@parentCategory={{this.model.subcategoryList.parentCategory}}
/>
{{/if}}
{{#if this.showTagInfo}}
<TagInfo @tag={{this.model.tag}} @list={{this.model.list}} />
{{/if}}
</:header>
<:list>
<Discovery::Topics
@period={{this.model.list.for_period}}
@changePeriod={{this.changePeriod}}
@model={{this.model.list}}
@canBulkSelect={{this.canBulkSelect}}
@bulkSelectHelper={{this.bulkSelectHelper}}
@showDismissRead={{this.showDismissRead}}
@showResetNew={{this.showResetNew}}
@category={{this.model.category}}
@tag={{this.model.tag}}
@changeSort={{this.changeSort}}
@changeNewListSubset={{this.changeNewListSubset}}
/>
</:list>
</Discovery::Layout>

View File

@ -1,13 +0,0 @@
{{body-class "navigation-categories"}}
<section class="navigation-container">
<DNavigation
@filterMode="categories"
@showCategoryAdmin={{this.showCategoryAdmin}}
@createCategory={{route-action "createCategory"}}
@reorderCategories={{route-action "reorderCategories"}}
@canCreateTopic={{this.canCreateTopic}}
@hasDraft={{this.currentUser.has_topic_draft}}
@createTopic={{fn this.composer.openNewTopic (hash preferDraft=true)}}
/>
</section>

View File

@ -1,45 +0,0 @@
<AddCategoryTagClasses @category={{this.category}} />
<PluginOutlet
@name="above-category-heading"
@outletArgs={{hash category=this.category}}
/>
<section class="category-heading">
{{#if this.category.uploaded_logo.url}}
<CategoryLogo @category={{this.category}} />
{{#if this.category.description}}
<p>{{dir-span this.category.description htmlSafe="true"}}</p>
{{/if}}
{{/if}}
<span>
<PluginOutlet
@name="category-heading"
@connectorTagName="div"
@outletArgs={{hash category=this.category}}
/>
</span>
</section>
<section class="navigation-container category-navigation">
<DNavigation
@category={{this.category}}
@filterMode={{this.filterMode}}
@noSubcategories={{this.noSubcategories}}
@canCreateTopic={{this.canCreateTopic}}
@createTopic={{fn
this.composer.openNewTopic
(hash category=this.createTopicTargetCategory preferDraft=true)
}}
@createTopicDisabled={{not this.enableCreateTopicButton}}
@hasDraft={{this.currentUser.has_topic_draft}}
@editCategory={{route-action "editCategory" this.category}}
/>
<PluginOutlet
@name="category-navigation"
@connectorTagName="div"
@outletArgs={{hash category=this.category}}
/>
</section>

View File

@ -1,11 +0,0 @@
{{body-class "navigation-topics"}}
<section class="navigation-container">
<DNavigation
@filterMode={{this.filterMode}}
@canCreateTopic={{this.canCreateTopic}}
@hasDraft={{this.currentUser.has_topic_draft}}
@createTopic={{fn this.composer.openNewTopic (hash preferDraft=true)}}
@skipCategoriesNavItem={{this.skipCategoriesNavItem}}
/>
</section>

View File

@ -1,189 +0,0 @@
{{#if this.list.canLoadMore}}
{{hide-application-footer}}
{{/if}}
{{body-class
"tags-page"
(concat "tag-" this.tag.id)
(if this.category.slug (concat "category-" this.category.slug))
(if this.additionalTags "tags-intersection")
}}
<div class="container">
<DiscourseBanner @user={{this.currentUser}} @banner={{this.site.banner}} />
</div>
<span>
<PluginOutlet @name="discovery-list-controls-above" @connectorTagName="div" />
</span>
<div class="list-controls">
<PluginOutlet
@name="discovery-navigation-bar-above"
@connectorTagName="div"
/>
<div class="container">
<section class="navigation-container tag-navigation">
<DNavigation
@filterMode={{this.filterMode}}
@canCreateTopic={{this.canCreateTopic}}
@hasDraft={{this.currentUser.has_topic_draft}}
@createTopic={{route-action "createTopic"}}
@category={{this.category}}
@editCategory={{route-action "editCategory" this.category}}
@tag={{this.tag}}
@noSubcategories={{this.noSubcategories}}
@tagNotification={{this.tagNotification}}
@additionalTags={{this.additionalTags}}
@showInfo={{this.showInfo}}
@canCreateTopicOnTag={{this.canCreateTopicOnTag}}
@createTopicDisabled={{this.createTopicDisabled}}
@changeTagNotificationLevel={{action "changeTagNotificationLevel"}}
@toggleInfo={{action "toggleInfo"}}
/>
<PluginOutlet
@name="tag-navigation"
@connectorTagName="div"
@outletArgs={{hash category=this.category tag=this.tag}}
/>
</section>
</div>
</div>
{{#if this.showInfo}}
<TagInfo
@tag={{this.tag}}
@list={{this.list}}
@deleteAction={{action "deleteTag"}}
/>
{{/if}}
<span>
<PluginOutlet
@name="discovery-list-container-top"
@connectorTagName="div"
@outletArgs={{hash category=this.category}}
/>
</span>
<TopicDismissButtons
@position="top"
@selectedTopics={{this.selected}}
@model={{this.model}}
@showResetNew={{this.showResetNew}}
@showDismissRead={{this.showDismissRead}}
@resetNew={{action "resetNew"}}
/>
<span>
<PluginOutlet @name="discovery-above" @connectorTagName="div" />
</span>
<div class="container list-container">
<div class="row">
<div class="full-width">
<PluginOutlet @name="before-list-area" />
<div id="list-area">
{{#unless this.loading}}
<DiscoveryTopicsList
@model={{this.list}}
@refresh={{action "refresh"}}
@autoAddTopicsToBulkSelect={{this.autoAddTopicsToBulkSelect}}
@bulkSelectEnabled={{this.bulkSelectEnabled}}
@addTopicsToBulkSelect={{action "addTopicsToBulkSelect"}}
>
{{#if this.top}}
<div class="top-lists">
<PeriodChooser
@period={{this.period}}
@action={{action "changePeriod"}}
@fullDay={{false}}
/>
</div>
{{else}}
{{#if this.topicTrackingState.hasIncoming}}
<div class="show-more {{if this.hasTopics 'has-topics'}}">
<a
tabindex="0"
href
{{on "click" this.showInserted}}
class="alert alert-info clickable"
>
<CountI18n
@key="topic_count_"
@suffix={{this.topicTrackingState.filter}}
@count={{this.topicTrackingState.incomingCount}}
/>
</a>
</div>
{{/if}}
{{/if}}
{{#unless this.bulkSelectEnabled}}
{{#if (and this.showTopicsAndRepliesToggle this.site.mobileView)}}
<NewListHeaderControlsWrapper
@current={{this.subset}}
@newRepliesCount={{this.newRepliesCount}}
@newTopicsCount={{this.newTopicsCount}}
@changeNewListSubset={{action "changeNewListSubset"}}
/>
{{/if}}
{{/unless}}
{{#if this.list.topics}}
<TopicList
@topics={{this.list.topics}}
@canBulkSelect={{this.canBulkSelect}}
@toggleBulkSelect={{action "toggleBulkSelect"}}
@bulkSelectEnabled={{this.bulkSelectEnabled}}
@bulkSelectAction={{action "refresh"}}
@updateAutoAddTopicsToBulkSelect={{action
"updateAutoAddTopicsToBulkSelect"
}}
@selected={{this.selected}}
@category={{this.category}}
@showPosters={{true}}
@order={{this.order}}
@ascending={{this.ascending}}
@changeSort={{action "changeSort"}}
@focusLastVisitedTopic={{true}}
@showTopicsAndRepliesToggle={{this.showTopicsAndRepliesToggle}}
@newListSubset={{this.subset}}
@changeNewListSubset={{action "changeNewListSubset"}}
@newRepliesCount={{this.newRepliesCount}}
@newTopicsCount={{this.newTopicsCount}}
/>
{{/if}}
</DiscoveryTopicsList>
<footer class="topic-list-bottom">
<TopicDismissButtons
@position="bottom"
@selectedTopics={{this.selected}}
@model={{this.model}}
@showResetNew={{this.showResetNew}}
@showDismissRead={{this.showDismissRead}}
@resetNew={{action "resetNew"}}
/>
{{#unless this.list.canLoadMore}}
<FooterMessage
@education={{this.footerEducation}}
@message={{this.footerMessage}}
>
{{html-safe
(i18n "topic.browse_all_tags_or_latest" basePath=(base-path))
}}
</FooterMessage>
{{/unless}}
</footer>
{{/unless}}
<ConditionalLoadingSpinner @condition={{this.list.loadingMore}} />
</div>
</div>
</div>
</div>
<span>
<PluginOutlet @name="discovery-below" @connectorTagName="div" />
</span>

View File

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<DiscourseBanner @user={{this.currentUser}} @banner={{this.site.banner}} /> <DiscourseBanner />
</div> </div>
<div class="container tags-index"> <div class="container tags-index">

View File

@ -19,8 +19,6 @@
<AddTopicStatusClasses @topic={{this.model}} /> <AddTopicStatusClasses @topic={{this.model}} />
<div class="container"> <div class="container">
<DiscourseBanner <DiscourseBanner
@user={{this.currentUser}}
@banner={{this.site.banner}}
@overlay={{this.hasScrolled}} @overlay={{this.hasScrolled}}
@hide={{this.model.errorLoading}} @hide={{this.model.errorLoading}}
/> />

View File

@ -15,11 +15,15 @@
> >
<TopicDismissButtons <TopicDismissButtons
@position="top" @position="top"
@selectedTopics={{this.selected}} @selectedTopics={{this.bulkSelectHelper.selected}}
@model={{this.model}} @model={{this.model}}
@showResetNew={{this.showResetNew}} @showResetNew={{this.showResetNew}}
@showDismissRead={{this.showDismissRead}} @showDismissRead={{this.showDismissRead}}
@resetNew={{action "resetNew"}} @resetNew={{action "resetNew"}}
@dismissRead={{if
this.showDismissRead
(route-action "dismissReadTopics")
}}
/> />
{{#if (gt this.incomingCount 0)}} {{#if (gt this.incomingCount 0)}}
@ -43,24 +47,22 @@
@topicList={{this.model}} @topicList={{this.model}}
@hideCategory={{this.hideCategory}} @hideCategory={{this.hideCategory}}
@showPosters={{this.showPosters}} @showPosters={{this.showPosters}}
@bulkSelectEnabled={{this.bulkSelectEnabled}}
@bulkSelectAction={{action "refresh"}}
@selected={{this.selected}}
@tagsForUser={{this.tagsForUser}} @tagsForUser={{this.tagsForUser}}
@canBulkSelect={{this.canBulkSelect}} @canBulkSelect={{this.canBulkSelect}}
@toggleBulkSelect={{action "toggleBulkSelect"}} @bulkSelectHelper={{this.bulkSelectHelper}}
@updateAutoAddTopicsToBulkSelect={{action
"updateAutoAddTopicsToBulkSelect"
}}
/> />
<TopicDismissButtons <TopicDismissButtons
@position="bottom" @position="bottom"
@selectedTopics={{this.selected}} @selectedTopics={{this.bulkSelectHelper.selected}}
@model={{this.model}} @model={{this.model}}
@showResetNew={{this.showResetNew}} @showResetNew={{this.showResetNew}}
@showDismissRead={{this.showDismissRead}} @showDismissRead={{this.showDismissRead}}
@resetNew={{action "resetNew"}} @resetNew={{action "resetNew"}}
@dismissRead={{if
this.showDismissRead
(route-action "dismissReadTopics")
}}
/> />
<ConditionalLoadingSpinner @condition={{this.model.loadingMore}} /> <ConditionalLoadingSpinner @condition={{this.model.loadingMore}} />

View File

@ -25,7 +25,7 @@
<div class="navigation-controls"> <div class="navigation-controls">
{{#if this.site.mobileView}} {{#if this.site.mobileView}}
{{#if this.currentUser.admin}} {{#if this.currentUser.admin}}
<BulkSelectToggle @parentController="user-topics-list" /> <BulkSelectToggle @bulkSelectHelper={{this.bulkSelectHelper}} />
{{/if}} {{/if}}
{{/if}} {{/if}}

View File

@ -3,8 +3,6 @@ globalThis.deprecationWorkflow.config = {
// We're using RAISE_ON_DEPRECATION in environment.js instead of // We're using RAISE_ON_DEPRECATION in environment.js instead of
// `throwOnUnhandled` here since it is easier to toggle. // `throwOnUnhandled` here since it is easier to toggle.
workflow: [ workflow: [
{ handler: "silence", matchId: "route-render-template" },
{ handler: "silence", matchId: "route-disconnect-outlet" },
{ {
handler: "silence", handler: "silence",
matchId: "ember-this-fallback.this-property-fallback", matchId: "ember-this-fallback.this-property-fallback",

View File

@ -543,7 +543,7 @@ acceptance("Tag info", function (needs) {
await visit("/tag/planters"); await visit("/tag/planters");
await click("#create-topic"); await click("#create-topic");
let composer = this.owner.lookup("service:composer"); let composer = this.owner.lookup("service:composer");
assert.strictEqual(composer.get("model").tags, undefined); assert.deepEqual(composer.get("model").tags, []);
}); });
}); });

View File

@ -1,8 +1,6 @@
import { click, currentURL, settled, visit } from "@ember/test-helpers"; import { click, currentURL, settled, visit } from "@ember/test-helpers";
import { skip, test } from "qunit"; import { skip, test } from "qunit";
import sinon from "sinon";
import { configureEyeline } from "discourse/lib/eyeline"; import { configureEyeline } from "discourse/lib/eyeline";
import DiscourseURL from "discourse/lib/url";
import { ScrollingDOMMethods } from "discourse/mixins/scrolling"; import { ScrollingDOMMethods } from "discourse/mixins/scrolling";
import discoveryFixtures from "discourse/tests/fixtures/discovery-fixtures"; import discoveryFixtures from "discourse/tests/fixtures/discovery-fixtures";
import { import {
@ -128,19 +126,13 @@ acceptance("Topic Discovery", function (needs) {
}); });
test("Using period chooser when query params are present", async function (assert) { test("Using period chooser when query params are present", async function (assert) {
await visit("/top?f=foo&d=bar"); await visit("/top?status=closed");
sinon.stub(DiscourseURL, "routeTo");
const periodChooser = selectKit(".period-chooser"); const periodChooser = selectKit(".period-chooser");
await periodChooser.expand(); await periodChooser.expand();
await periodChooser.selectRowByValue("yearly"); await periodChooser.selectRowByValue("yearly");
assert.ok( assert.strictEqual(currentURL(), "/top?period=yearly&status=closed");
DiscourseURL.routeTo.calledWith("/top?f=foo&d=bar&period=yearly"),
"it keeps the query params"
);
}); });
test("switching between tabs", async function (assert) { test("switching between tabs", async function (assert) {

View File

@ -1,6 +1,5 @@
import { click, currentURL, visit } from "@ember/test-helpers"; import { click, currentURL, visit } from "@ember/test-helpers";
import { test } from "qunit"; import { test } from "qunit";
import sinon from "sinon";
import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers";
acceptance("Category 404", function (needs) { acceptance("Category 404", function (needs) {
@ -15,16 +14,10 @@ acceptance("Category 404", function (needs) {
}); });
test("Navigating to a bad category link does not break the router", async function (assert) { test("Navigating to a bad category link does not break the router", async function (assert) {
// Don't log the XHR error
const stub = sinon
.stub(console, "error")
.withArgs(sinon.match({ status: 404 }));
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click('[data-for-test="category-404"]'); await click('[data-for-test="category-404"]');
assert.strictEqual(currentURL(), "/404"); assert.strictEqual(currentURL(), "/404");
sinon.assert.calledOnce(stub);
// See that we can navigate away // See that we can navigate away
await click("#site-logo"); await click("#site-logo");

View File

@ -2,6 +2,7 @@ import { getOwner } from "@ember/application";
import { click, render } from "@ember/test-helpers"; import { click, render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars"; import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit"; import { module, test } from "qunit";
import BulkSelectHelper from "discourse/lib/bulk-select-helper";
import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | topic-list", function (hooks) { module("Integration | Component | topic-list", function (hooks) {
@ -14,54 +15,47 @@ module("Integration | Component | topic-list", function (hooks) {
store.createRecord("topic", { id: 24234 }), store.createRecord("topic", { id: 24234 }),
store.createRecord("topic", { id: 24235 }), store.createRecord("topic", { id: 24235 }),
], ],
selected: [], bulkSelectHelper: new BulkSelectHelper(this),
bulkSelectEnabled: false,
autoAddTopicsToBulkSelect: false,
toggleBulkSelect() {
this.toggleProperty("bulkSelectEnabled");
},
updateAutoAddTopicsToBulkSelect(newVal) {
this.set("autoAddTopicsToBulkSelect", newVal);
},
}); });
await render(hbs` await render(hbs`
<TopicList <TopicList
@canBulkSelect={{true}} @canBulkSelect={{true}}
@toggleBulkSelect={{this.toggleBulkSelect}} @bulkSelectHelper={{this.bulkSelectHelper}}
@bulkSelectEnabled={{this.bulkSelectEnabled}}
@autoAddTopicsToBulkSelect={{this.autoAddTopicsToBulkSelect}}
@updateAutoAddTopicsToBulkSelect={{this.updateAutoAddTopicsToBulkSelect}}
@topics={{this.topics}} @topics={{this.topics}}
@selected={{this.selected}}
/> />
`); `);
assert.strictEqual(this.selected.length, 0, "defaults to 0"); assert.strictEqual(
this.bulkSelectHelper.selected.length,
0,
"defaults to 0"
);
await click("button.bulk-select"); await click("button.bulk-select");
assert.ok(this.bulkSelectEnabled, "bulk select is enabled"); assert.true(
this.bulkSelectHelper.bulkSelectEnabled,
"bulk select is enabled"
);
await click("button.bulk-select-all"); await click("button.bulk-select-all");
assert.strictEqual( assert.strictEqual(
this.selected.length, this.bulkSelectHelper.selected.length,
2, 2,
"clicking Select All selects all loaded topics" "clicking Select All selects all loaded topics"
); );
assert.ok( assert.true(
this.autoAddTopicsToBulkSelect, this.bulkSelectHelper.autoAddTopicsToBulkSelect,
"clicking Select All turns on the autoAddTopicsToBulkSelect flag" "clicking Select All turns on the autoAddTopicsToBulkSelect flag"
); );
await click("button.bulk-clear-all"); await click("button.bulk-clear-all");
assert.strictEqual( assert.strictEqual(
this.selected.length, this.bulkSelectHelper.selected.length,
0, 0,
"clicking Clear All deselects all topics" "clicking Clear All deselects all topics"
); );
assert.ok( assert.false(
!this.autoAddTopicsToBulkSelect, this.bulkSelectHelper.autoAddTopicsToBulkSelect,
"clicking Clear All turns off the autoAddTopicsToBulkSelect flag" "clicking Clear All turns off the autoAddTopicsToBulkSelect flag"
); );
}); });

View File

@ -38,7 +38,7 @@ class TopicQuery
end end
def self.public_valid_options def self.public_valid_options
# For these to work in Ember, add them to `controllers/discovery-sortable.js` # For these to work in Ember, add them to `controllers/discovery/list.js`
@public_valid_options ||= %i[ @public_valid_options ||= %i[
page page
before before