FEATURE: allow admins to enable announced experimental features (#29244)

Toggle the button to enable the experimental site setting from "What's new" announcement.

The toggle button is displayed when:
- site setting exists and is boolean;
- potentially required plugin is enabled.
This commit is contained in:
Krzysztof Kotlarek 2024-10-22 10:56:58 +11:00 committed by GitHub
parent 644e6c7f46
commit 433fadbd52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 363 additions and 46 deletions

View File

@ -1,50 +1,141 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import { and, not } from "truth-helpers";
import CookText from "discourse/components/cook-text";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
const DashboardNewFeatureItem = <template>
<div class="admin-new-feature-item">
<div class="admin-new-feature-item__content">
<div class="admin-new-feature-item__header">
{{#if (and @item.emoji (not @item.screenshot_url))}}
<div class="admin-new-feature-item__new-feature-emoji">
{{@item.emoji}}
export default class DiscourseNewFeatureItem extends Component {
@service siteSettings;
@service toasts;
@tracked experimentEnabled;
@tracked toggleExperimentDisabled = false;
@bind
initEnabled() {
this.experimentEnabled =
this.siteSettings[this.args.item.experiment_setting];
}
@action
async toggleExperiment() {
if (this.toggleExperimentDisabled) {
this.toasts.error({
duration: 3000,
data: {
message: I18n.t(
"admin.dashboard.new_features.experiment_toggled_too_fast"
),
},
});
return;
}
this.experimentEnabled = !this.experimentEnabled;
this.toggleExperimentDisabled = true;
setTimeout(() => {
this.toggleExperimentDisabled = false;
}, 5000);
try {
await ajax("/admin/toggle-feature", {
type: "POST",
data: {
setting_name: this.args.item.experiment_setting,
},
});
this.toasts.success({
duration: 3000,
data: {
message: this.experimentEnabled
? I18n.t("admin.dashboard.new_features.experiment_enabled")
: I18n.t("admin.dashboard.new_features.experiment_disabled"),
},
});
} catch (error) {
this.experimentEnabled = !this.experimentEnabled;
return popupAjaxError(error);
}
}
<template>
<div class="admin-new-feature-item" {{didInsert this.initEnabled}}>
<div class="admin-new-feature-item__content">
<div class="admin-new-feature-item__header">
{{#if (and @item.emoji (not @item.screenshot_url))}}
<div class="admin-new-feature-item__new-feature-emoji">
{{@item.emoji}}
</div>
{{/if}}
<h3>
{{@item.title}}
</h3>
{{#if @item.discourse_version}}
<div class="admin-new-feature-item__new-feature-version">
{{@item.discourse_version}}
</div>
{{/if}}
</div>
<div class="admin-new-feature-item__body">
{{#if @item.screenshot_url}}
<img
src={{@item.screenshot_url}}
class="admin-new-feature-item__screenshot"
alt={{@item.title}}
/>
{{/if}}
<div class="admin-new-feature-item__feature-description">
<CookText @rawText={{@item.description}} />
{{#if @item.link}}
<a
href={{@item.link}}
target="_blank"
rel="noopener noreferrer"
class="admin-new-feature-item__learn-more"
>
{{i18n "admin.dashboard.new_features.learn_more"}}
</a>
{{/if}}
</div>
{{/if}}
<h3>
{{@item.title}}
</h3>
{{#if @item.discourse_version}}
<div class="admin-new-feature-item__new-feature-version">
{{@item.discourse_version}}
</div>
{{/if}}
</div>
{{#if @item.screenshot_url}}
<img
src={{@item.screenshot_url}}
class="admin-new-feature-item__screenshot"
alt={{@item.title}}
/>
{{/if}}
<div class="admin-new-feature-item__feature-description">
<CookText @rawText={{@item.description}} />
{{#if @item.link}}
<a
href={{@item.link}}
target="_blank"
rel="noopener noreferrer"
class="admin-new-feature-item__learn-more"
>
{{i18n "admin.dashboard.new_features.learn_more"}}
</a>
{{/if}}
{{#if @item.experiment_setting}}
<div class="admin-new-feature-item__feature-toggle">
<DTooltip>
<:trigger>
<DToggleSwitch
@state={{this.experimentEnabled}}
{{on "click" this.toggleExperiment}}
/>
</:trigger>
<:content>
<div class="admin-new-feature-item__tooltip">
<div class="admin-new-feature-item__tooltip-header">
{{i18n
"admin.dashboard.new_features.experiment_tooltip.title"
}}
</div>
<div class="admin-new-feature-item__tooltip-content">
{{i18n
"admin.dashboard.new_features.experiment_tooltip.content"
}}
</div>
</div>
</:content>
</DTooltip>
</div>
{{/if}}
</div>
</div>
</div>
</div>
</template>;
export default DashboardNewFeatureItem;
</template>
}

View File

@ -659,6 +659,24 @@
}
}
.admin-new-feature-item__body {
display: flex;
justify-content: space-between;
.d-toggle-switch {
margin-left: 1em;
align-items: flex-start;
}
p {
margin-top: 0;
}
}
.admin-new-feature-item__tooltip-header {
font-weight: bold;
}
.admin-new-feature-item__tooltip-content {
margin-top: 0.5em;
}
.admin-new-feature-item {
display: flex;
align-items: flex-start;

View File

@ -48,6 +48,18 @@ class Admin::DashboardController < Admin::StaffController
render json: data
end
def toggle_feature
Experiments::Toggle.call(service_params) do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:current_user_is_admin) { raise Discourse::InvalidAccess }
on_failed_policy(:setting_is_available) { raise Discourse::InvalidAccess }
on_failed_contract do |contract|
render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400)
end
end
end
private
def mark_new_features_as_seen

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
class Experiments::Toggle
include Service::Base
policy :current_user_is_admin
contract do
attribute :setting_name, :string
validates :setting_name, presence: true
end
policy :setting_is_available
transaction { step :toggle }
private
def current_user_is_admin(guardian:)
guardian.is_admin?
end
def setting_is_available(contract:)
SiteSetting.respond_to?(contract.setting_name)
end
def toggle(contract:, guardian:)
SiteSetting.set_and_log(
contract.setting_name,
!SiteSetting.send(contract.setting_name),
guardian.user,
)
end
end

View File

@ -5104,6 +5104,13 @@ en:
subtitle: "We are releasing new features and improvements all the time. This page covers the highlights, but you can click 'Learn more' to see extensive release notes."
previous_announcements: "You can see previous new feature announcements on <a href='%{url}' target='_blank'>Discourse Meta</a>"
learn_more: "Learn more..."
experiment_enabled: "You have enabled the experimental feature."
experiment_disabled: "You have disabled the experimental feature."
experiment_toggled_too_fast: "You have toggled the experimental feature too fast. Please wait a few seconds before trying again."
experiment_tooltip:
title: "Try our experimental feature"
content: "Give our newest feature in development a spin! It's still in the experimental stage, so we might remove it at any time. You can opt-out whenever you like."
last_checked: "Last checked"
refresh_problems: "Refresh"
no_problems: "No problems were found."

View File

@ -326,6 +326,7 @@ Discourse::Application.routes.draw do
get "dashboard/reports" => "dashboard#reports"
get "dashboard/whats-new" => "dashboard#new_features"
get "/whats-new" => "dashboard#new_features"
post "/toggle-feature" => "dashboard#toggle_feature"
resources :dashboard, only: [:index] do
collection { get "problems" }

View File

@ -150,10 +150,24 @@ module DiscourseUpdates
end
return nil if entries.nil?
entries.map! do |item|
next item if !item["experiment_setting"]
item["experiment_setting"] = nil if !SiteSetting.respond_to?(item["experiment_setting"]) ||
SiteSetting.type_supervisor.get_type(item["experiment_setting"].to_sym) != :bool
item
end
entries.select! do |item|
begin
item["discourse_version"].nil? ||
Discourse.has_needed_version?(current_version, item["discourse_version"])
valid_version =
item["discourse_version"].nil? ||
Discourse.has_needed_version?(current_version, item["discourse_version"])
valid_plugin_name =
item["plugin_name"].nil? || Discourse.plugins_by_name[item["plugin_name"]].present?
valid_version && valid_plugin_name
rescue StandardError
nil
end

View File

@ -248,6 +248,64 @@ RSpec.describe DiscourseUpdates do
expect(result[1]["title"]).to eq("Whistles")
expect(result[2]["title"]).to eq("Bells")
end
it "correctly shows features with correct boolean experimental site settings" do
features_with_versions = [
{
"emoji" => "🤾",
"title" => "Bells",
"created_at" => 2.days.ago,
"experiment_setting" => "enable_mobile_theme",
},
{
"emoji" => "🙈",
"title" => "Whistles",
"created_at" => 3.days.ago,
"experiment_setting" => "default_theme_id",
},
{
"emoji" => "🙈",
"title" => "Confetti",
"created_at" => 4.days.ago,
"experiment_setting" => "wrong value",
},
]
Discourse.redis.set("new_features", MultiJson.dump(features_with_versions))
DiscourseUpdates.last_installed_version = "2.7.0.beta2"
result = DiscourseUpdates.new_features
expect(result.length).to eq(3)
expect(result[0]["experiment_setting"]).to eq("enable_mobile_theme")
expect(result[1]["experiment_setting"]).to be_nil
expect(result[2]["experiment_setting"]).to be_nil
end
it "correctly shows features when related plugins are installed" do
Discourse.stubs(:plugins_by_name).returns({ "discourse-ai" => true })
features_with_versions = [
{
"emoji" => "🤾",
"title" => "Bells",
"created_at" => 2.days.ago,
"plugin_name" => "discourse-ai",
},
{
"emoji" => "🙈",
"title" => "Confetti",
"created_at" => 4.days.ago,
"plugin_name" => "uninstalled-plugin",
},
]
Discourse.redis.set("new_features", MultiJson.dump(features_with_versions))
DiscourseUpdates.last_installed_version = "2.7.0.beta2"
result = DiscourseUpdates.new_features
expect(result.length).to eq(1)
expect(result[0]["title"]).to eq("Bells")
end
end
describe "#get_last_viewed_feature_date" do

View File

@ -9,7 +9,7 @@ RSpec.describe "Running Sidekiq Jobs in Multisite", type: :multisite do
it "CheckNewFeatures should only hit the payload once" do
# otherwise it will get rate-limited by meta
DiscourseUpdates.expects(:new_features_payload).returns("{}").once
DiscourseUpdates.expects(:new_features_payload).returns([]).once
Jobs::CheckNewFeatures.new.perform({})
end
end

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
RSpec.describe Experiments::Toggle do
subject(:result) { described_class.call(params) }
describe described_class::Contract, type: :model do
subject(:contract) { described_class.new }
it { is_expected.to validate_presence_of :setting_name }
end
fab!(:admin)
let(:params) { { setting_name:, guardian: } }
let(:setting_name) { :experimental_form_templates }
let(:guardian) { admin.guardian }
context "when setting_name is blank" do
let(:setting_name) { nil }
it { is_expected.to fail_a_contract }
end
context "when setting_name is invalid" do
let(:setting_name) { "wrong_value" }
it { is_expected.to fail_a_policy(:setting_is_available) }
end
context "when a non-admin user tries to change a setting" do
let(:guardian) { Guardian.new }
it { is_expected.to fail_a_policy(:current_user_is_admin) }
end
context "when the admin toggles the feature" do
it { is_expected.to run_successfully }
it "enables the specified setting" do
expect { result }.to change { SiteSetting.experimental_form_templates }.to(true)
end
it "disables the specified setting" do
SiteSetting.experimental_form_templates = true
expect { result }.to change { SiteSetting.experimental_form_templates }.to(false)
end
it "creates an entry in the staff action logs" do
expect { result }.to change {
UserHistory.where(
action: UserHistory.actions[:change_site_setting],
subject: "experimental_form_templates",
).count
}.by(1)
end
end
end

View File

@ -90,6 +90,28 @@ describe "Admin New Features Page", type: :system do
expect(new_features_page).to have_no_screenshot
end
it "displays experimental feature toggle" do
DiscourseUpdates.stubs(:new_features).returns(
[
{
"id" => 7,
"user_id" => 1,
"emoji" => "😍",
"title" => "New feature",
"description" => "New feature description",
"link" => "https://meta.discourse.org",
"tier" => [],
"discourse_version" => "",
"created_at" => "2023-11-10T02:52:41.462Z",
"updated_at" => "2023-11-10T04:28:47.020Z",
"experiment_setting" => "experimental_form_templates",
},
],
)
new_features_page.visit
expect(new_features_page).to have_toggle_experiment_button
end
it "displays a new feature indicator on the sidebar and clears it when navigating to what's new" do
DiscourseUpdates.stubs(:has_unseen_features?).returns(true)
visit "/admin"

View File

@ -16,6 +16,10 @@ module PageObjects
page.has_no_css?(".admin-new-feature-item__screenshot")
end
def has_toggle_experiment_button?
page.has_css?(".admin-new-feature-item__feature-toggle")
end
def has_learn_more_link?
page.has_css?(".admin-new-feature-item__learn-more")
end