diff --git a/app/assets/javascripts/admin/addon/components/dashboard-new-features.js b/app/assets/javascripts/admin/addon/components/dashboard-new-features.js new file mode 100644 index 00000000000..937340c1d36 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/dashboard-new-features.js @@ -0,0 +1,26 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; + +export default Component.extend({ + newFeatures: null, + releaseNotesLink: null, + + init() { + this._super(...arguments); + + ajax("/admin/dashboard/new-features.json").then((json) => { + this.setProperties({ + newFeatures: json.new_features, + releaseNotesLink: json.release_notes_link, + }); + }); + }, + + @action + dismissNewFeatures() { + ajax("/admin/dashboard/mark-new-features-as-seen.json", { + type: "PUT", + }).then(() => this.set("newFeatures", null)); + }, +}); diff --git a/app/assets/javascripts/admin/addon/models/admin-dashboard.js b/app/assets/javascripts/admin/addon/models/admin-dashboard.js index 400ebe161bb..c0c9f86702c 100644 --- a/app/assets/javascripts/admin/addon/models/admin-dashboard.js +++ b/app/assets/javascripts/admin/addon/models/admin-dashboard.js @@ -14,6 +14,7 @@ AdminDashboard.reopenClass({ return ajax("/admin/dashboard.json").then((json) => { const model = AdminDashboard.create(); model.set("version_check", json.version_check); + return model; }); }, diff --git a/app/assets/javascripts/admin/addon/templates/components/dashboard-new-feature-item.hbs b/app/assets/javascripts/admin/addon/templates/components/dashboard-new-feature-item.hbs new file mode 100644 index 00000000000..2aff2986402 --- /dev/null +++ b/app/assets/javascripts/admin/addon/templates/components/dashboard-new-feature-item.hbs @@ -0,0 +1,13 @@ +
+
{{item.emoji}}
+
+
+ {{#if item.link}} + {{item.title}} + {{else}} + {{item.title}} + {{/if}} +
+
{{item.description}}
+
+
diff --git a/app/assets/javascripts/admin/addon/templates/components/dashboard-new-features.hbs b/app/assets/javascripts/admin/addon/templates/components/dashboard-new-features.hbs new file mode 100644 index 00000000000..190e53a4326 --- /dev/null +++ b/app/assets/javascripts/admin/addon/templates/components/dashboard-new-features.hbs @@ -0,0 +1,21 @@ +{{#if newFeatures}} +
+
+

{{replace-emoji (i18n "admin.dashboard.new_features.title") }}

+
+ +
+ {{#each newFeatures as |feature|}} + {{dashboard-new-feature-item item=feature}} + {{/each}} +
+ +
+{{/if}} diff --git a/app/assets/javascripts/admin/addon/templates/dashboard.hbs b/app/assets/javascripts/admin/addon/templates/dashboard.hbs index 0e4f4beec26..cb87e84b471 100644 --- a/app/assets/javascripts/admin/addon/templates/dashboard.hbs +++ b/app/assets/javascripts/admin/addon/templates/dashboard.hbs @@ -1,3 +1,5 @@ +{{dashboard-new-features}} + {{plugin-outlet name="admin-dashboard-top"}} {{#if showVersionChecks}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/dashboard-test.js b/app/assets/javascripts/discourse/tests/acceptance/dashboard-test.js index b573383066b..ac67b052df5 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/dashboard-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/dashboard-test.js @@ -127,6 +127,13 @@ acceptance("Dashboard", function (needs) { "its set the value of the filter from the query params" ); }); + + test("new features", async function (assert) { + await visit("/admin"); + + assert.ok(exists(".dashboard-new-features")); + assert.ok(exists(".dashboard-new-features .new-features-release-notes")); + }); }); acceptance("Dashboard: dashboard_visible_tabs", function (needs) { diff --git a/app/assets/javascripts/discourse/tests/fixtures/dashboard-new-features.js b/app/assets/javascripts/discourse/tests/fixtures/dashboard-new-features.js new file mode 100644 index 00000000000..fe9d50b791f --- /dev/null +++ b/app/assets/javascripts/discourse/tests/fixtures/dashboard-new-features.js @@ -0,0 +1,32 @@ +export default { + "/admin/dashboard/new-features.json": { + new_features: [ + { + id: 1, + user_id: 127, + emoji: "😎", + title: "New color palettes", + description: + "New light and dark color palettes that adhere to Web Content Accessibility Guidelines. ", + tier: [], + link: "https://meta.discourse.org", + created_at: "2021-01-18T19:59:29.666Z", + updated_at: "2021-01-19T19:33:16.150Z", + }, + { + id: 7, + user_id: 127, + emoji: "👱‍♀️", + title: "Suspend users quickly", + description: + "Staff can now suspend or silence a user immediately, without needing to visit the review queue or admin page. ", + tier: [], + link: "", + created_at: "2021-01-19T19:20:09.757Z", + updated_at: "2021-01-19T19:20:09.757Z", + } + ], + release_notes_link: + "https://meta.discourse.org/c/feature/announcements?tags=release-notes\u0026before=0", + }, +}; diff --git a/app/assets/stylesheets/common/admin/dashboard.scss b/app/assets/stylesheets/common/admin/dashboard.scss index 8d38420b7bd..0e553c4a9a8 100644 --- a/app/assets/stylesheets/common/admin/dashboard.scss +++ b/app/assets/stylesheets/common/admin/dashboard.scss @@ -612,3 +612,42 @@ font-size: $font-up-3; } } + +.dashboard-new-features { + .section-body { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + grid-gap: 1.5em; + } + + .section-footer { + margin: 1.5em; + display: flex; + justify-content: flex-end; + align-items: center; + .btn { + margin-left: 1em; + } + } +} + +.admin-new-feature-item { + display: flex; + align-items: flex-start; + + .new-feature-emoji { + font-size: 3.5em; + padding-right: 0.5em; + padding-left: 0.5em; + } + + .new-feature-content { + padding-right: 0.5em; + align-self: center; + .header { + font-size: $font-up-1; + font-weight: bold; + margin-bottom: 0.5em; + } + } +} diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index eb246d75e54..1b75618f883 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -22,4 +22,15 @@ class Admin::DashboardController < Admin::AdminController def problems render_json_dump(problems: AdminDashboardData.fetch_problems(check_force_https: request.ssl?)) end + + def new_features + data = { new_features: DiscourseUpdates.unseen_new_features(current_user.id) } + data.merge!(release_notes_link: AdminDashboardGeneralData.fetch_cached_stats["release_notes_link"]) + render json: data + end + + def mark_new_features_as_seen + DiscourseUpdates.mark_new_features_as_seen(current_user.id) + render json: success_json + end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 330dcb19814..30f998f7b36 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3601,6 +3601,10 @@ en: installed_version: "Installed" latest_version: "Latest" problems_found: "Some advice based on your current site settings" + new_features: + title: "🎁 New Features" + dismiss: "Dismiss" + learn_more: "Learn more" last_checked: "Last checked" refresh_problems: "Refresh" no_problems: "No problems were found." diff --git a/config/routes.rb b/config/routes.rb index e30b0a7d2c8..85c40920445 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -260,6 +260,8 @@ Discourse::Application.routes.draw do get "dashboard/moderation" => "dashboard#moderation" get "dashboard/security" => "dashboard#security" get "dashboard/reports" => "dashboard#reports" + get "dashboard/new-features" => "dashboard#new_features" + put "dashboard/mark-new-features-as-seen" => "dashboard#mark_new_features_as_seen" resources :dashboard, only: [:index] do collection do diff --git a/config/site_settings.yml b/config/site_settings.yml index a64dba360ce..d32b2ebc49d 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -2134,6 +2134,10 @@ uncategorized: client: true hidden: true + check_for_new_features: + default: false + hidden: true + automatically_unpin_topics: default: true client: true diff --git a/lib/discourse_updates.rb b/lib/discourse_updates.rb index 6a7dc2a54b4..d5d2f3b2647 100644 --- a/lib/discourse_updates.rb +++ b/lib/discourse_updates.rb @@ -115,6 +115,38 @@ module DiscourseUpdates keys.present? ? keys.map { |k| Discourse.redis.hgetall(k) } : [] end + def perform_new_feature_check + response = Excon.new(new_features_endpoint).request(expects: [200], method: :Get) + json = JSON.parse(response.body) + Discourse.redis.set(new_features_key, response.body) + end + + def unseen_new_features(user_id) + entries = JSON.parse(Discourse.redis.get(new_features_key)) rescue nil + return nil if entries.nil? + + last_seen = new_features_last_seen(user_id) + + if last_seen.present? + entries.select! { |item| Time.zone.parse(item["created_at"]) > last_seen } + end + + entries.sort { |item| Time.zone.parse(item["created_at"]) } + end + + def new_features_last_seen(user_id) + last_seen = Discourse.redis.get new_features_last_seen_key(user_id) + return nil if last_seen.blank? + Time.zone.parse(last_seen) + end + + def mark_new_features_as_seen(user_id) + entries = JSON.parse(Discourse.redis.get(new_features_key)) rescue nil + return nil if entries.nil? + last_seen = entries.max_by { |x| x["created_at"] } + Discourse.redis.set(new_features_last_seen_key(user_id), last_seen["created_at"]) + end + private def last_installed_version_key @@ -144,5 +176,17 @@ module DiscourseUpdates def missing_versions_key_prefix 'missing_version' end + + def new_features_endpoint + 'https://meta.discourse.org/new-features.json' + end + + def new_features_key + 'new_features' + end + + def new_features_last_seen_key(user_id) + "new_features_last_seen_user_#{user_id}" + end end end diff --git a/spec/components/discourse_updates_spec.rb b/spec/components/discourse_updates_spec.rb index 4b83510c245..e3f911ca6f2 100644 --- a/spec/components/discourse_updates_spec.rb +++ b/spec/components/discourse_updates_spec.rb @@ -144,4 +144,72 @@ describe DiscourseUpdates do include_examples "when last_installed_version is old" end end + + context 'new features' do + fab!(:admin) { Fabricate(:admin) } + fab!(:admin2) { Fabricate(:admin) } + let!(:last_item_date) { 5.minutes.ago } + let!(:sample_features) { [ + { "emoji" => "🤾", "title" => "Super Fruits", "description" => "Taste explosion!", "created_at" => 40.minutes.ago }, + { "emoji" => "🙈", "title" => "Fancy Legumes", "description" => "Magic legumes!", "created_at" => 15.minutes.ago }, + { "emoji" => "🤾", "title" => "Quality Veggies", "description" => "Green goodness!", "created_at" => last_item_date }, + ] } + + before(:each) do + Discourse.redis.del "new_features_last_seen_user_#{admin.id}" + Discourse.redis.del "new_features_last_seen_user_#{admin2.id}" + Discourse.redis.del "new_features" + + Discourse.redis.set('new_features', MultiJson.dump(sample_features)) + end + + it 'returns all items on the first run' do + result = DiscourseUpdates.unseen_new_features(admin.id) + + expect(result.length).to eq(3) + expect(result[2]["title"]).to eq("Super Fruits") + end + + it 'returns only unseen items by user' do + DiscourseUpdates.stubs(:new_features_last_seen).with(admin.id).returns(10.minutes.ago) + DiscourseUpdates.stubs(:new_features_last_seen).with(admin2.id).returns(30.minutes.ago) + + result = DiscourseUpdates.unseen_new_features(admin.id) + expect(result.length).to eq(1) + expect(result[0]["title"]).to eq("Quality Veggies") + + result2 = DiscourseUpdates.unseen_new_features(admin2.id) + expect(result2.length).to eq(2) + expect(result2[0]["title"]).to eq("Quality Veggies") + expect(result2[1]["title"]).to eq("Fancy Legumes") + end + + it 'can mark features as seen for a given user' do + expect(DiscourseUpdates.unseen_new_features(admin.id)).to be_present + + DiscourseUpdates.mark_new_features_as_seen(admin.id) + expect(DiscourseUpdates.unseen_new_features(admin.id)).to be_empty + + # doesn't affect another user + expect(DiscourseUpdates.unseen_new_features(admin2.id)).to be_present + + end + + it 'correctly sees newly added features as unseen' do + DiscourseUpdates.mark_new_features_as_seen(admin.id) + expect(DiscourseUpdates.unseen_new_features(admin.id)).to be_empty + expect(DiscourseUpdates.new_features_last_seen(admin.id)).to be_within(1.second).of (last_item_date) + + updated_features = [ + { "emoji" => "🤾", "title" => "Brand New Item", "created_at" => 2.minutes.ago } + ] + updated_features += sample_features + + Discourse.redis.set('new_features', MultiJson.dump(updated_features)) + + result = DiscourseUpdates.unseen_new_features(admin.id) + expect(result.length).to eq(1) + expect(result[0]["title"]).to eq("Brand New Item") + end + end end diff --git a/spec/requests/admin/dashboard_controller_spec.rb b/spec/requests/admin/dashboard_controller_spec.rb index 46602ae91d3..b838f169dc7 100644 --- a/spec/requests/admin/dashboard_controller_spec.rb +++ b/spec/requests/admin/dashboard_controller_spec.rb @@ -15,6 +15,15 @@ describe Admin::DashboardController do context 'while logged in as an admin' do fab!(:admin) { Fabricate(:admin) } + def populate_new_features + sample_features = [ + { "id" => "1", "emoji" => "🤾", "title" => "Cool Beans", "description" => "Now beans are included", "created_at" => Time.zone.now - 40.minutes }, + { "id" => "2", "emoji" => "🙈", "title" => "Fancy Legumes", "description" => "Legumes too!", "created_at" => Time.zone.now - 20.minutes } + ] + + Discourse.redis.set('new_features', MultiJson.dump(sample_features)) + end + before do sign_in(admin) end @@ -77,5 +86,49 @@ describe Admin::DashboardController do end end end + + describe '#new_features' do + before do + Discourse.redis.del "new_features_last_seen_user_#{admin.id}" + Discourse.redis.del "new_features" + end + + it 'is empty by default' do + get "/admin/dashboard/new-features.json" + expect(response.status).to eq(200) + json = response.parsed_body + expect(json['new_features']).to eq(nil) + end + + it 'fails gracefully for invalid JSON' do + Discourse.redis.set("new_features", "INVALID JSON") + get "/admin/dashboard/new-features.json" + expect(response.status).to eq(200) + json = response.parsed_body + expect(json['new_features']).to eq(nil) + end + + it 'includes new features when available' do + populate_new_features + + get "/admin/dashboard/new-features.json" + expect(response.status).to eq(200) + json = response.parsed_body + + expect(json['new_features'].length).to eq(2) + expect(json['new_features'][0]["emoji"]).to eq("🙈") + expect(json['new_features'][0]["title"]).to eq("Fancy Legumes") + end + end + + describe '#mark_new_features_as_seen' do + it 'resets last seen for a given user' do + populate_new_features + put "/admin/dashboard/mark-new-features-as-seen.json" + + expect(response.status).to eq(200) + expect(DiscourseUpdates.new_features_last_seen(admin.id)).not_to eq(nil) + end + end end end