FEATURE: Allow admins to force refresh "What's new?" (#29911)

Sometimes changes to "What's new?" feed items are made or the feed items are
removed altogether, and the polling interval to check for new features is 1 day.

This is quite long, so this commit introduces a "Check for updates"
button for admins to click on the "What's new?" page which will bust
the cache for the feed and check again at the new features endpoint.
This is limited to 5 times per minute to avoid rapid sending of
requests.
This commit is contained in:
Martin Brennan 2024-11-27 09:40:55 +10:00 committed by GitHub
parent b9f183e2c3
commit 2ef9d6ac47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 142 additions and 48 deletions

View File

@ -1,10 +1,12 @@
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class"; import concatClass from "discourse/helpers/concat-class";
import { i18n } from "discourse-i18n";
const AdminConfigAreaEmptyList = <template> const AdminConfigAreaEmptyList = <template>
<div class="admin-config-area-empty-list"> <div class="admin-config-area-empty-list">
{{i18n @emptyLabel}} {{htmlSafe @emptyLabel}}
{{#if @ctaLabel}}
<DButton <DButton
@label={{@ctaLabel}} @label={{@ctaLabel}}
class={{concatClass class={{concatClass
@ -14,6 +16,7 @@ const AdminConfigAreaEmptyList = <template>
@action={{@ctaAction}} @action={{@ctaAction}}
@route={{@ctaRoute}} @route={{@ctaRoute}}
/> />
{{/if}}
</div> </div>
</template>; </template>;

View File

@ -3,10 +3,13 @@ import { tracked } from "@glimmer/tracking";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
import AdminConfigAreaCard from "admin/components/admin-config-area-card"; import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import DashboardNewFeatureItem from "admin/components/dashboard-new-feature-item"; import DashboardNewFeatureItem from "admin/components/dashboard-new-feature-item";
export default class DashboardNewFeatures extends Component { export default class DashboardNewFeatures extends Component {
@ -14,12 +17,22 @@ export default class DashboardNewFeatures extends Component {
@tracked newFeatures = null; @tracked newFeatures = null;
@tracked groupedNewFeatures = null; @tracked groupedNewFeatures = null;
@tracked isLoaded = false; @tracked isLoading = true;
constructor() {
super(...arguments);
this.args.onCheckForFeatures(this.loadNewFeatures);
}
@bind @bind
loadNewFeatures() { async loadNewFeatures(opts = {}) {
ajax("/admin/whats-new.json") opts.forceRefresh ||= false;
.then((json) => { this.isLoading = true;
try {
const json = await ajax(
"/admin/whats-new.json?force_refresh=" + opts.forceRefresh
);
const items = json.new_features.reduce((acc, feature) => { const items = json.new_features.reduce((acc, feature) => {
const key = moment(feature.released_at || feature.created_at).format( const key = moment(feature.released_at || feature.created_at).format(
"YYYY-MM" "YYYY-MM"
@ -37,11 +50,11 @@ export default class DashboardNewFeatures extends Component {
features: items[date], features: items[date],
}; };
}); });
this.isLoaded = true; } catch (err) {
}) popupAjaxError(err);
.finally(() => { } finally {
this.isLoaded = true; this.isLoading = false;
}); }
} }
<template> <template>
@ -49,7 +62,7 @@ export default class DashboardNewFeatures extends Component {
class="admin-config-area__primary-content" class="admin-config-area__primary-content"
{{didInsert this.loadNewFeatures}} {{didInsert this.loadNewFeatures}}
> >
{{#if this.groupedNewFeatures}} <ConditionalLoadingSpinner @condition={{this.isLoading}}>
{{#each this.groupedNewFeatures as |groupedFeatures|}} {{#each this.groupedNewFeatures as |groupedFeatures|}}
<AdminConfigAreaCard @translatedHeading={{groupedFeatures.date}}> <AdminConfigAreaCard @translatedHeading={{groupedFeatures.date}}>
<:content> <:content>
@ -58,15 +71,17 @@ export default class DashboardNewFeatures extends Component {
{{/each}} {{/each}}
</:content> </:content>
</AdminConfigAreaCard> </AdminConfigAreaCard>
{{/each}} {{else}}
{{else if this.isLoaded}} <AdminConfigAreaEmptyList
{{htmlSafe @emptyLabel={{htmlSafe
(i18n (i18n
"admin.dashboard.new_features.previous_announcements" "admin.dashboard.new_features.previous_announcements"
url="https://meta.discourse.org/tags/c/announcements/67/release-notes" url="https://meta.discourse.org/tags/c/announcements/67/release-notes"
) )
}} }}
{{/if}} />
{{/each}}
</ConditionalLoadingSpinner>
</div> </div>
</template> </template>
} }

View File

@ -0,0 +1,14 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
export default class AdminWhatsNewController extends Controller {
@action
checkForUpdates() {
this.checkFeaturesCallback?.({ forceRefresh: true });
}
@action
bindCheckFeatures(checkFeaturesCallback) {
this.checkFeaturesCallback = checkFeaturesCallback;
}
}

View File

@ -10,10 +10,16 @@
@label={{i18n "admin.dashboard.new_features.title"}} @label={{i18n "admin.dashboard.new_features.title"}}
/> />
</:breadcrumbs> </:breadcrumbs>
<:actions as |actions|>
<actions.Primary
@label="admin.new_features.check_for_updates"
@action={{this.checkForUpdates}}
/>
</:actions>
</AdminPageHeader> </AdminPageHeader>
<div class="admin-container admin-config-page__main-area"> <div class="admin-container admin-config-page__main-area">
<div class="admin-config-area"> <div class="admin-config-area">
<DashboardNewFeatures /> <DashboardNewFeatures @onCheckForFeatures={{this.bindCheckFeatures}} />
</div> </div>
</div> </div>

View File

@ -31,7 +31,19 @@ class Admin::DashboardController < Admin::StaffController
end end
def new_features def new_features
new_features = DiscourseUpdates.new_features force_refresh = params[:force_refresh] == "true"
if force_refresh
RateLimiter.new(
current_user,
"force-refresh-new-features",
5,
1.minute,
apply_limit_to_staff: true,
).performed!
end
new_features = DiscourseUpdates.new_features(force_refresh:)
if current_user.admin? && most_recent = new_features&.first if current_user.admin? && most_recent = new_features&.first
DiscourseUpdates.bump_last_viewed_feature_date(current_user.id, most_recent["created_at"]) DiscourseUpdates.bump_last_viewed_feature_date(current_user.id, most_recent["created_at"])

View File

@ -5115,6 +5115,7 @@ en:
new_features: new_features:
title: "What's new" title: "What's new"
check_for_updates: "Check for updates"
dashboard: dashboard:
title: "Dashboard" title: "Dashboard"
last_updated: "Dashboard updated:" last_updated: "Dashboard updated:"

View File

@ -132,7 +132,8 @@ module DiscourseUpdates
end end
def new_features_payload def new_features_payload
response = Excon.new(new_features_endpoint).request(expects: [200], method: :Get) response =
Excon.new(new_features_endpoint).request(expects: [200], method: :Get, read_timeout: 5)
response.body response.body
end end
@ -141,7 +142,9 @@ module DiscourseUpdates
Discourse.redis.set(new_features_key, payload) Discourse.redis.set(new_features_key, payload)
end end
def new_features def new_features(force_refresh: false)
update_new_features if force_refresh
entries = entries =
begin begin
JSON.parse(Discourse.redis.get(new_features_key)) JSON.parse(Discourse.redis.get(new_features_key))

View File

@ -308,6 +308,14 @@ RSpec.describe DiscourseUpdates do
expect(result[0]["title"]).to eq("Bells") expect(result[0]["title"]).to eq("Bells")
expect(result[1]["title"]).to eq("Whistles") expect(result[1]["title"]).to eq("Whistles")
end end
it "correctly refetches features if force_refresh is used" do
DiscourseUpdates.expects(:update_new_features).once
result = DiscourseUpdates.new_features
expect(result.length).to eq(3)
result = DiscourseUpdates.new_features(force_refresh: true)
expect(result.length).to eq(3)
end
end end
describe "#get_last_viewed_feature_date" do describe "#get_last_viewed_feature_date" do

View File

@ -191,6 +191,38 @@ RSpec.describe Admin::DashboardController do
expect(json["has_unseen_features"]).to eq(true) expect(json["has_unseen_features"]).to eq(true)
end end
it "allows for forcing a refresh of new features, busting the cache" do
populate_new_features
get "/admin/whats-new.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["new_features"].length).to eq(2)
get "/admin/whats-new.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["new_features"].length).to eq(2)
DiscourseUpdates.stubs(:new_features_payload).returns(
[
{
"id" => "3",
"emoji" => "🚀",
"title" => "Space platform launched!",
"description" => "Now to make it to the next planet unscathed...",
"created_at" => 1.minute.ago,
},
].to_json,
)
get "/admin/whats-new.json?force_refresh=true"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["new_features"].length).to eq(1)
expect(json["new_features"][0]["id"]).to eq("3")
end
it "passes unseen feature state" do it "passes unseen feature state" do
populate_new_features populate_new_features
DiscourseUpdates.mark_new_features_as_seen(admin.id) DiscourseUpdates.mark_new_features_as_seen(admin.id)