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,19 +1,22 @@
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { i18n } from "discourse-i18n";
const AdminConfigAreaEmptyList = <template>
<div class="admin-config-area-empty-list">
{{i18n @emptyLabel}}
<DButton
@label={{@ctaLabel}}
class={{concatClass
"btn-default btn-small admin-config-area-empty-list__cta-button"
@ctaClass
}}
@action={{@ctaAction}}
@route={{@ctaRoute}}
/>
{{htmlSafe @emptyLabel}}
{{#if @ctaLabel}}
<DButton
@label={{@ctaLabel}}
class={{concatClass
"btn-default btn-small admin-config-area-empty-list__cta-button"
@ctaClass
}}
@action={{@ctaAction}}
@route={{@ctaRoute}}
/>
{{/if}}
</div>
</template>;

View File

@ -3,10 +3,13 @@ import { tracked } from "@glimmer/tracking";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n";
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";
export default class DashboardNewFeatures extends Component {
@ -14,34 +17,44 @@ export default class DashboardNewFeatures extends Component {
@tracked newFeatures = null;
@tracked groupedNewFeatures = null;
@tracked isLoaded = false;
@tracked isLoading = true;
constructor() {
super(...arguments);
this.args.onCheckForFeatures(this.loadNewFeatures);
}
@bind
loadNewFeatures() {
ajax("/admin/whats-new.json")
.then((json) => {
const items = json.new_features.reduce((acc, feature) => {
const key = moment(feature.released_at || feature.created_at).format(
"YYYY-MM"
);
acc[key] = acc[key] || [];
acc[key].push(feature);
return acc;
}, {});
async loadNewFeatures(opts = {}) {
opts.forceRefresh ||= false;
this.isLoading = true;
this.groupedNewFeatures = Object.keys(items).map((date) => {
return {
date: moment
.tz(date, this.currentUser.user_option.timezone)
.format("MMMM YYYY"),
features: items[date],
};
});
this.isLoaded = true;
})
.finally(() => {
this.isLoaded = true;
try {
const json = await ajax(
"/admin/whats-new.json?force_refresh=" + opts.forceRefresh
);
const items = json.new_features.reduce((acc, feature) => {
const key = moment(feature.released_at || feature.created_at).format(
"YYYY-MM"
);
acc[key] = acc[key] || [];
acc[key].push(feature);
return acc;
}, {});
this.groupedNewFeatures = Object.keys(items).map((date) => {
return {
date: moment
.tz(date, this.currentUser.user_option.timezone)
.format("MMMM YYYY"),
features: items[date],
};
});
} catch (err) {
popupAjaxError(err);
} finally {
this.isLoading = false;
}
}
<template>
@ -49,7 +62,7 @@ export default class DashboardNewFeatures extends Component {
class="admin-config-area__primary-content"
{{didInsert this.loadNewFeatures}}
>
{{#if this.groupedNewFeatures}}
<ConditionalLoadingSpinner @condition={{this.isLoading}}>
{{#each this.groupedNewFeatures as |groupedFeatures|}}
<AdminConfigAreaCard @translatedHeading={{groupedFeatures.date}}>
<:content>
@ -58,15 +71,17 @@ export default class DashboardNewFeatures extends Component {
{{/each}}
</:content>
</AdminConfigAreaCard>
{{else}}
<AdminConfigAreaEmptyList
@emptyLabel={{htmlSafe
(i18n
"admin.dashboard.new_features.previous_announcements"
url="https://meta.discourse.org/tags/c/announcements/67/release-notes"
)
}}
/>
{{/each}}
{{else if this.isLoaded}}
{{htmlSafe
(i18n
"admin.dashboard.new_features.previous_announcements"
url="https://meta.discourse.org/tags/c/announcements/67/release-notes"
)
}}
{{/if}}
</ConditionalLoadingSpinner>
</div>
</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"}}
/>
</:breadcrumbs>
<:actions as |actions|>
<actions.Primary
@label="admin.new_features.check_for_updates"
@action={{this.checkForUpdates}}
/>
</:actions>
</AdminPageHeader>
<div class="admin-container admin-config-page__main-area">
<div class="admin-config-area">
<DashboardNewFeatures />
<DashboardNewFeatures @onCheckForFeatures={{this.bindCheckFeatures}} />
</div>
</div>

View File

@ -31,7 +31,19 @@ class Admin::DashboardController < Admin::StaffController
end
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
DiscourseUpdates.bump_last_viewed_feature_date(current_user.id, most_recent["created_at"])

View File

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

View File

@ -132,7 +132,8 @@ module DiscourseUpdates
end
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
end
@ -141,7 +142,9 @@ module DiscourseUpdates
Discourse.redis.set(new_features_key, payload)
end
def new_features
def new_features(force_refresh: false)
update_new_features if force_refresh
entries =
begin
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[1]["title"]).to eq("Whistles")
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
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)
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
populate_new_features
DiscourseUpdates.mark_new_features_as_seen(admin.id)