mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 18:13:38 +08:00
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:
parent
644e6c7f46
commit
433fadbd52
|
@ -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 { and, not } from "truth-helpers";
|
||||||
import CookText from "discourse/components/cook-text";
|
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 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>
|
export default class DiscourseNewFeatureItem extends Component {
|
||||||
<div class="admin-new-feature-item">
|
@service siteSettings;
|
||||||
<div class="admin-new-feature-item__content">
|
@service toasts;
|
||||||
<div class="admin-new-feature-item__header">
|
@tracked experimentEnabled;
|
||||||
{{#if (and @item.emoji (not @item.screenshot_url))}}
|
@tracked toggleExperimentDisabled = false;
|
||||||
<div class="admin-new-feature-item__new-feature-emoji">
|
|
||||||
{{@item.emoji}}
|
@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>
|
</div>
|
||||||
{{/if}}
|
{{#if @item.experiment_setting}}
|
||||||
<h3>
|
<div class="admin-new-feature-item__feature-toggle">
|
||||||
{{@item.title}}
|
<DTooltip>
|
||||||
</h3>
|
<:trigger>
|
||||||
{{#if @item.discourse_version}}
|
<DToggleSwitch
|
||||||
<div class="admin-new-feature-item__new-feature-version">
|
@state={{this.experimentEnabled}}
|
||||||
{{@item.discourse_version}}
|
{{on "click" this.toggleExperiment}}
|
||||||
</div>
|
/>
|
||||||
{{/if}}
|
</:trigger>
|
||||||
</div>
|
<:content>
|
||||||
|
<div class="admin-new-feature-item__tooltip">
|
||||||
{{#if @item.screenshot_url}}
|
<div class="admin-new-feature-item__tooltip-header">
|
||||||
<img
|
{{i18n
|
||||||
src={{@item.screenshot_url}}
|
"admin.dashboard.new_features.experiment_tooltip.title"
|
||||||
class="admin-new-feature-item__screenshot"
|
}}
|
||||||
alt={{@item.title}}
|
</div>
|
||||||
/>
|
<div class="admin-new-feature-item__tooltip-content">
|
||||||
{{/if}}
|
{{i18n
|
||||||
|
"admin.dashboard.new_features.experiment_tooltip.content"
|
||||||
<div class="admin-new-feature-item__feature-description">
|
}}
|
||||||
<CookText @rawText={{@item.description}} />
|
</div>
|
||||||
|
</div>
|
||||||
{{#if @item.link}}
|
</:content>
|
||||||
<a
|
</DTooltip>
|
||||||
href={{@item.link}}
|
</div>
|
||||||
target="_blank"
|
{{/if}}
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
class="admin-new-feature-item__learn-more"
|
|
||||||
>
|
|
||||||
{{i18n "admin.dashboard.new_features.learn_more"}}
|
|
||||||
</a>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>;
|
}
|
||||||
|
|
||||||
export default DashboardNewFeatureItem;
|
|
||||||
|
|
|
@ -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 {
|
.admin-new-feature-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
|
@ -48,6 +48,18 @@ class Admin::DashboardController < Admin::StaffController
|
||||||
render json: data
|
render json: data
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def mark_new_features_as_seen
|
def mark_new_features_as_seen
|
||||||
|
|
34
app/services/experiments/toggle.rb
Normal file
34
app/services/experiments/toggle.rb
Normal 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
|
|
@ -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."
|
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>"
|
previous_announcements: "You can see previous new feature announcements on <a href='%{url}' target='_blank'>Discourse Meta</a>"
|
||||||
learn_more: "Learn more..."
|
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"
|
last_checked: "Last checked"
|
||||||
refresh_problems: "Refresh"
|
refresh_problems: "Refresh"
|
||||||
no_problems: "No problems were found."
|
no_problems: "No problems were found."
|
||||||
|
|
|
@ -326,6 +326,7 @@ Discourse::Application.routes.draw do
|
||||||
get "dashboard/reports" => "dashboard#reports"
|
get "dashboard/reports" => "dashboard#reports"
|
||||||
get "dashboard/whats-new" => "dashboard#new_features"
|
get "dashboard/whats-new" => "dashboard#new_features"
|
||||||
get "/whats-new" => "dashboard#new_features"
|
get "/whats-new" => "dashboard#new_features"
|
||||||
|
post "/toggle-feature" => "dashboard#toggle_feature"
|
||||||
|
|
||||||
resources :dashboard, only: [:index] do
|
resources :dashboard, only: [:index] do
|
||||||
collection { get "problems" }
|
collection { get "problems" }
|
||||||
|
|
|
@ -150,10 +150,24 @@ module DiscourseUpdates
|
||||||
end
|
end
|
||||||
return nil if entries.nil?
|
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|
|
entries.select! do |item|
|
||||||
begin
|
begin
|
||||||
item["discourse_version"].nil? ||
|
valid_version =
|
||||||
Discourse.has_needed_version?(current_version, item["discourse_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
|
rescue StandardError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
|
@ -248,6 +248,64 @@ RSpec.describe DiscourseUpdates do
|
||||||
expect(result[1]["title"]).to eq("Whistles")
|
expect(result[1]["title"]).to eq("Whistles")
|
||||||
expect(result[2]["title"]).to eq("Bells")
|
expect(result[2]["title"]).to eq("Bells")
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "#get_last_viewed_feature_date" do
|
describe "#get_last_viewed_feature_date" do
|
||||||
|
|
|
@ -9,7 +9,7 @@ RSpec.describe "Running Sidekiq Jobs in Multisite", type: :multisite do
|
||||||
|
|
||||||
it "CheckNewFeatures should only hit the payload once" do
|
it "CheckNewFeatures should only hit the payload once" do
|
||||||
# otherwise it will get rate-limited by meta
|
# 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({})
|
Jobs::CheckNewFeatures.new.perform({})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
56
spec/services/experiments/toggle_spec.rb
Normal file
56
spec/services/experiments/toggle_spec.rb
Normal 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
|
|
@ -90,6 +90,28 @@ describe "Admin New Features Page", type: :system do
|
||||||
expect(new_features_page).to have_no_screenshot
|
expect(new_features_page).to have_no_screenshot
|
||||||
end
|
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
|
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)
|
DiscourseUpdates.stubs(:has_unseen_features?).returns(true)
|
||||||
visit "/admin"
|
visit "/admin"
|
||||||
|
|
|
@ -16,6 +16,10 @@ module PageObjects
|
||||||
page.has_no_css?(".admin-new-feature-item__screenshot")
|
page.has_no_css?(".admin-new-feature-item__screenshot")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def has_toggle_experiment_button?
|
||||||
|
page.has_css?(".admin-new-feature-item__feature-toggle")
|
||||||
|
end
|
||||||
|
|
||||||
def has_learn_more_link?
|
def has_learn_more_link?
|
||||||
page.has_css?(".admin-new-feature-item__learn-more")
|
page.has_css?(".admin-new-feature-item__learn-more")
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue
Block a user