FEATURE: Upcoming changes part 1 (#34617)

This PR introduces part one of the "Upcoming changes" interface for
Discourse admins.

The upcoming changes feature is an enhancement around our existing
site-setting based feature flagging and experiments system. With some
light metadata, we can give admins a much better overview of the current
work we are doing, with ways for them to opt-out in early stages and
opt-in to things that we haven’t yet turned on by default for them.

This system, along with encouraging a more liberal use of site setting
flags for features, experiments, and refactors in the app, should
minimise the problem of breakages and disruptions for all Discourse
users. It is also our intent with this system for it to be easier for
designers to add and remove these changes.

Finally, it also gives us a kind of running changelog that we can use to
communicate with site owners before releases and “What’s new?” updates.

### FOR REVIEWERS

This initial PR is gated behind a hidden `enable_upcoming_changes` site
setting, because there is still more work to do before we reveal this to
admins.

To test the UI, you can add this metadata under any boolean-based site
setting, though upcoming change settings will specifically be hidden:

```
upcoming_change:
   status: "alpha" (see UpcomingChanges.statuses.keys)
   impact: "feature,staff" (feature|other for the first part, staff|admins|moderators|all_members|developers for the second part)
   learn_more_url: "https://some.url"
```

To test the images, add an image under `public/images/upcoming_changes`
with the file name as `SITE_SETTING_NAME.png`

### Interface

Admins can see the following in the interface for upcoming changes:

* The status of the change. Changes can progress along these statuses:
   * Pre-Alpha
   * Alpha
   * Beta
   * Stable
   * Permanent
* The impact of the change. This is split into Type and Role. Type can
be "Feature" or "Other" for now. Changes may affect the following roles:
   * Admins
   * Moderators
   * Staff
   * All members
* The plugin that is making the change
* The groups that are opted-in to the change. Admins can control these
groups for a gradual rollout. If a change is enabled, it is limited to
these groups.
* In some cases, an image related to the change, behind the "Preview"
link
* A link to learn more about the change

Admins can filter the changes by name, description, plugin, status,
impact type, and whether the change is enabled.

### Promotion system

For our hosted customers, we intend to have a status-based
auto-promotion system as changes progress.

For all sites, once a change reaches the Stable status, if an admin
opts-out of that change it will generate an admin problem message that
will be shown on the dashboard.

For self-hosted Discourse admins, changes will only be forcibly enabled
when they reach the Permanent state.

### Notification system

A notification system for upcoming changes so admins can stay informed
will be added in a followup PR.

---------

Co-authored-by: awesomerobot <kris.aubuchon@discourse.org>
This commit is contained in:
Martin Brennan
2025-10-30 10:46:14 +10:00
committed by GitHub
co-authored by awesomerobot
parent 93862b98dd
commit d4ac43e605
58 changed files with 3622 additions and 114 deletions
@@ -1276,3 +1276,4 @@ a.inline-editable-field {
@import "admin/color-palette-editor";
@import "admin/admin_config_color_palettes";
@import "admin/admin_config_components";
@import "admin/upcoming-changes";
@@ -7,6 +7,17 @@
flex: 1 1 auto;
}
&.--multiple-dropdowns {
display: flex;
flex-direction: column;
}
&__inputs {
display: flex;
gap: var(--space-2);
flex: 1 1 auto;
}
&__dropdown {
max-width: min(14em, 35vw);
flex: 1 1 auto;
+1 -1
View File
@@ -132,7 +132,7 @@
}
td.context {
word-break: break-all;
max-width: 20vw;
}
}
@@ -0,0 +1,126 @@
.upcoming-changes-table {
table-layout: fixed;
width: 100%;
td.d-table__cell {
vertical-align: middle;
padding: var(--space-4) var(--space-3);
}
td.d-table__cell.--detail.upcoming-change__metadata {
height: 100%;
}
}
.upcoming-change {
&__name-header {
width: 45%;
}
&__groups-header {
width: 35%;
}
&__enabled-header {
width: 10%;
text-align: center;
}
&__opt-in-groups-label {
display: block;
font-size: var(--font-down-1);
color: var(--primary-medium);
}
&__badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
min-height: var(--space-8);
}
&__image-preview.lightbox[href] {
// very specific to override
&:hover {
box-shadow: none;
}
& + .upcoming-change__learn-more {
&::before {
content: "";
}
}
}
&__badge {
font-size: var(--font-down-2);
padding: 0.15rem 0.3rem;
border-radius: 4px;
color: var(--primary-very-high);
background: var(--tertiary-low);
letter-spacing: 0.25px;
align-items: center;
display: inline-flex;
gap: var(--space-1);
svg {
height: 0.75em;
color: var(--primary-high);
}
&.--status-permanent {
background-color: var(--success-low);
}
}
&__groups {
display: flex;
.group-selector-wrapper {
// make space for button
width: calc(100% - 3.125em);
}
}
&__description-details {
display: inline;
}
&__save-groups {
align-self: start;
margin-left: var(--space-2);
}
&__toggle-cell {
&.d-table__cell.--detail {
vertical-align: top;
}
.d-toggle-switch {
padding-top: var(--space-1);
justify-content: center;
}
}
&__plugin {
font-size: var(--font-down-2);
color: var(--primary-high);
margin-bottom: var(--space-2);
}
&__labels &__badges {
display: flex;
align-items: center;
justify-content: flex-start;
height: 100%;
margin: 0;
padding: var(--space-2) 0;
}
&__permanent-notice {
font-size: var(--font-down-1);
color: var(--primary-medium);
margin-top: var(--space-2);
}
}
@@ -0,0 +1,35 @@
# frozen_string_literal: true
#
class Admin::Config::UpcomingChangesController < Admin::AdminController
def index
return if !request.xhr?
UpcomingChanges::List.call(service_params) do
on_success { |upcoming_changes:| render(json: upcoming_changes) }
on_failed_policy(:current_user_is_admin) { raise Discourse::InvalidAccess }
end
end
def update_groups
SiteSetting::UpsertGroups.call(service_params) do |result|
on_success { render(json: success_json) }
on_model_not_found(:group_ids) { raise Discourse::NotFound }
on_failed_contract do |contract|
render(json: failed_json.merge(errors: contract.errors.full_messages), status: :bad_request)
end
on_failed_policy(:current_user_is_admin) { raise Discourse::InvalidAccess }
end
end
def toggle_change
UpcomingChanges::Toggle.call(service_params) do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: :unprocessable_entity) }
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: :bad_request)
end
end
end
end
@@ -61,7 +61,7 @@ class Admin::DashboardController < Admin::StaffController
end
def toggle_feature
Experiments::Toggle.call(service_params) do
UpcomingChanges::Toggle.call(service_params) do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: :unprocessable_entity) }
on_failed_policy(:current_user_is_admin) { raise Discourse::InvalidAccess }
+9 -2
View File
@@ -88,6 +88,7 @@ class ProblemCheck
ProblemCheck::TwitterLogin,
ProblemCheck::UnreachableThemes,
ProblemCheck::WatchedWords,
ProblemCheck::UpcomingChangeStableOptedOut,
].freeze
# To enforce the unique constraint in Postgres <15 we need a dummy
@@ -178,9 +179,13 @@ class ProblemCheck
problems
.uniq(&:target)
.each do |problem|
problem_translation_data =
problem.target.present? ? translation_data(problem.target) : translation_data
tracker(problem.target).problem!(
next_run_at:,
details: translation_data.merge(problem.details).merge(base_path: Discourse.base_path),
details:
problem_translation_data.merge(problem.details).merge(base_path: Discourse.base_path),
)
end
end
@@ -195,6 +200,8 @@ class ProblemCheck
end
def problem(target = nil, override_key: nil, override_data: {}, details: {})
target_identifier = target.kind_of?(ActiveRecord::Base) ? target.id : target
problem =
Problem.new(
I18n.t(
@@ -206,7 +213,7 @@ class ProblemCheck
),
priority: self.config.priority,
identifier:,
target: target&.id,
target: target_identifier,
details:,
)
+7
View File
@@ -230,6 +230,13 @@ class SiteSetting < ActiveRecord::Base
end
end
def self.history_for(setting_name)
UserHistory.where(
action: UserHistory.actions[:change_site_setting],
subject: setting_name,
).order(created_at: :desc)
end
class ImageQuality
def self.png_to_jpg_quality
SiteSetting.png_to_jpg_quality.nonzero? || SiteSetting.image_quality
+47
View File
@@ -0,0 +1,47 @@
# frozen_string_literal: true
# An array of group_ids stored in the format "1|2|3"
# associated with a site setting. This allows us to restrict
# a non-group-list site setting with a list of groups,
# which is necessary for example for "Upcoming changes" settings.
class SiteSettingGroup < ActiveRecord::Base
belongs_to :site_setting
validates :name, presence: true, uniqueness: true
def self.setting_group_ids
return {} unless can_access_db?
DB
.query("SELECT name, group_ids FROM site_setting_groups")
.each_with_object({}) do |row, hash|
hash[row.name.to_sym] = row.group_ids.split("|").map(&:to_i)
end
end
def self.generate_setting_group_map
return {} unless can_access_db?
Hash[*SiteSettingGroup.setting_group_ids.flatten]
end
def self.can_access_db?
!GlobalSetting.skip_redis? && !GlobalSetting.skip_db? &&
ActiveRecord::Base.connection.table_exists?(self.table_name)
end
end
# == Schema Information
#
# Table name: site_setting_groups
#
# id :bigint not null, primary key
# group_ids :string not null
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_site_setting_groups_on_name (name) UNIQUE
#
+10
View File
@@ -1942,6 +1942,16 @@ class User < ActiveRecord::Base
.where(ip_address: self.ip_address, admin: false, moderator: false)
end
def upcoming_change_enabled?(upcoming_change)
setting_enabled = SiteSetting.public_send(upcoming_change)
if UpcomingChanges.has_groups?(upcoming_change)
return setting_enabled && in_any_groups?(UpcomingChanges.group_ids_for(upcoming_change))
end
setting_enabled
end
protected
def badge_grant
+4
View File
@@ -158,6 +158,8 @@ class UserHistory < ActiveRecord::Base
delete_associated_accounts: 119,
change_theme_site_setting: 120,
stop_impersonating: 121,
upcoming_change_toggled: 122,
change_site_setting_groups: 123,
)
end
@@ -282,6 +284,8 @@ class UserHistory < ActiveRecord::Base
create_flag
change_theme_site_setting
stop_impersonating
upcoming_change_toggled
change_site_setting_groups
]
end
+13 -1
View File
@@ -82,7 +82,8 @@ class CurrentUserSerializer < BasicUserSerializer
:effective_locale,
:use_reviewable_ui_refresh,
:can_see_ip,
:is_impersonating
:is_impersonating,
:upcoming_changes
delegate :user_stat, to: :object, private: true
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
@@ -370,4 +371,15 @@ class CurrentUserSerializer < BasicUserSerializer
def include_can_see_ip?
object.admin? || (object.moderator? && SiteSetting.moderators_view_ips)
end
# TODO (martin) A page for members to see what upcoming changes
# they have enabled??
#
def upcoming_changes
SiteSetting
.upcoming_change_site_settings
.each_with_object({}) do |upcoming_change, hash|
hash[upcoming_change] = object.upcoming_change_enabled?(upcoming_change)
end
end
end
-33
View File
@@ -1,33 +0,0 @@
# frozen_string_literal: true
class Experiments::Toggle
include Service::Base
policy :current_user_is_admin
params 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(params:)
SiteSetting.respond_to?(params.setting_name)
end
def toggle(params:, guardian:)
SiteSetting.set_and_log(
params.setting_name,
!SiteSetting.public_send(params.setting_name),
guardian.user,
)
end
end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class ProblemCheck::UpcomingChangeStableOptedOut < ProblemCheck
self.perform_every = 1.hour
def call
return no_problem if !SiteSetting.enable_upcoming_changes
status_errors
end
private
def translation_data(upcoming_change)
{ upcoming_change: SiteSetting.humanized_name(upcoming_change) }
end
def targets
SiteSetting.upcoming_change_site_settings
end
def status_errors
targets
.map do |upcoming_change|
# If the site setting is enabled, then the change is opted in, either
# manually or automatically, so we skip it.
next if SiteSetting.send(upcoming_change)
# Don't care about any changes that are not yet stable, admins can opt
# in and out of these without worry.
next if UpcomingChanges.not_yet_stable?(upcoming_change)
# At this point, we have an upcoming change that is stable or permanent,
# and the site is opted out of it. Admins need to know that the change
# will either become permanent or be removed soon.
problem(upcoming_change)
end
.compact
end
end
@@ -0,0 +1,80 @@
# frozen_string_literal: true
class SiteSetting::UpsertGroups
include Service::Base
params do
before_validation { self.group_names = Array.wrap(self.group_names).delete_if(&:empty?) }
attribute :group_names, :array
attribute :setting, :string
validates :setting, presence: true
end
policy :current_user_is_admin
only_if(:provided_group_names) do
model :group_ids
transaction do
step :upsert_site_setting_groups
step :log_site_setting_groups_change
end
end
only_if(:no_provided_group_names) do
transaction do
step :delete_site_setting_group
step :log_site_setting_groups_change
end
end
step :notify_changed
private
def provided_group_names(params:)
params.group_names.present?
end
def no_provided_group_names(params:)
!provided_group_names(params:)
end
def current_user_is_admin(guardian:)
guardian.is_admin?
end
def fetch_group_ids(params:)
Group.where(name: params.group_names).pluck(:id)
end
def upsert_site_setting_groups(params:, group_ids:, guardian:)
context[:previous_value] = SiteSettingGroup.find_by(name: params.setting)&.group_ids
context[:new_value] = group_ids.sort.join("|")
SiteSettingGroup.upsert(
{ name: params.setting, group_ids: context[:new_value] },
unique_by: :name,
)
end
def delete_site_setting_group(params:, guardian:)
previous_record = SiteSettingGroup.find_by(name: params.setting)
context[:previous_value] = previous_record&.group_ids
context[:new_value] = ""
previous_record&.destroy!
end
def log_site_setting_groups_change(params:, guardian:, new_value:, previous_value:)
StaffActionLogger.new(guardian.user).log_site_setting_groups_change(
params.setting,
previous_value,
new_value,
)
end
def notify_changed
SiteSetting.refresh_site_setting_group_ids!
SiteSetting.notify_changed!
end
end
+31
View File
@@ -258,6 +258,37 @@ class StaffActionLogger
)
end
# This is different from site settings because, even though an upcoming change
# is a site setting, we want to be able to distinguish it from the other hundreds
# of other site setting change events, so we need a unique distinguishing type
def log_upcoming_change_toggle(setting_name, previous_value, new_value, opts = {})
unless setting_name.present? && SiteSetting.respond_to?(setting_name)
raise Discourse::InvalidParameters.new(:setting_name)
end
UserHistory.create!(
params(opts).merge(
action: UserHistory.actions[:upcoming_change_toggled],
subject: setting_name,
previous_value: previous_value ? "true" : "false",
new_value: new_value ? "true" : "false",
),
)
end
def log_site_setting_groups_change(setting_name, previous_value, new_value, opts = {})
unless setting_name.present? && SiteSetting.respond_to?(setting_name)
raise Discourse::InvalidParameters.new(:setting_name)
end
UserHistory.create!(
params(opts).merge(
action: UserHistory.actions[:change_site_setting_groups],
subject: setting_name,
previous_value: previous_value&.to_s,
new_value: new_value&.to_s,
),
)
end
def theme_json(theme)
ThemeSerializer.new(theme, root: false, include_theme_field_values: true).to_json
end
+65
View File
@@ -0,0 +1,65 @@
# frozen_string_literal: true
class UpcomingChanges::List
include Service::Base
policy :current_user_is_admin
model :upcoming_changes, optional: true
step :load_upcoming_change_groups
step :sort_changes
private
def current_user_is_admin(guardian:)
guardian.is_admin?
end
def fetch_upcoming_changes
SiteSetting
.all_settings(
only_upcoming_changes: true,
include_hidden: true,
include_locale_setting: false,
)
.each do |setting|
setting[:value] = setting[:value] == "true"
if UpcomingChanges.image_exists?(setting[:setting])
setting[:upcoming_change][:image] = UpcomingChanges.image_data(setting[:setting])
end
if setting[:plugin]
plugin = Discourse.plugins_by_name[setting[:plugin]]
# NOTE (martin) Maybe later we add a URL or something? Not sure.
# Then the plugin name could be clicked in the UI
setting[:plugin] = plugin.humanized_name
end
end
.map do |setting|
# We don't need to return all the other setting metadata for
# endpoints that use this.
setting.slice(:setting, :humanized_name, :description, :value, :upcoming_change, :plugin)
end
end
def load_upcoming_change_groups(upcoming_changes:)
group_ids =
upcoming_changes
.map { |change| SiteSetting.site_setting_group_ids[change[:setting]] }
.flatten
.compact
.uniq
groups = Group.where(id: group_ids).pluck(:id, :name).to_h
upcoming_changes.each do |setting|
group_ids_for_setting = SiteSetting.site_setting_group_ids[setting[:setting]]
setting[:groups] = groups.values_at(*group_ids_for_setting) if group_ids_for_setting.present?
end
end
def sort_changes(upcoming_changes:)
context[:upcoming_changes] = upcoming_changes.sort_by { |change| change[:setting] }
end
end
+47
View File
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class UpcomingChanges::Toggle
include Service::Base
params do
attribute :setting_name, :string
validates :setting_name, presence: true
end
policy :current_user_is_admin
policy :setting_is_available
transaction { step :toggle }
private
def current_user_is_admin(guardian:)
guardian.is_admin?
end
def setting_is_available(params:)
SiteSetting.respond_to?(params.setting_name)
end
def toggle(params:, guardian:)
# TODO (martin) Remove this once we release upcoming changes,
# otherwise it will be confusing for people to see log messages
# about upcoming changes via "What's new?" experimental toggles
# before we update that UI.
if SiteSetting.enable_upcoming_changes
previous_value = SiteSetting.public_send(params.setting_name)
SiteSetting.send("#{params.setting_name}=", !previous_value)
StaffActionLogger.new(guardian.user).log_upcoming_change_toggle(
params.setting_name,
previous_value,
!previous_value,
{ context: I18n.t("staff_action_logs.upcoming_changes.log_manually_toggled") },
)
else
SiteSetting.set_and_log(
params.setting_name,
!SiteSetting.public_send(params.setting_name),
guardian.user,
)
end
end
end
+55
View File
@@ -5630,6 +5630,8 @@ en:
# This is a text input placeholder, keep the translation short
type_to_filter: "Type to filter…"
settings: "Settings"
reset_filter: "Reset"
toggle_filters: "Toggle dropdown filters"
admin:
title: "Discourse Admin"
@@ -5667,6 +5669,54 @@ en:
advanced:
title: "Advanced"
upcoming_changes:
name: "Name"
plugin: "Plugin"
metadata: "Metadata"
enabled: "Enabled"
show_image: "Show image"
edit_groups: "Edit opt-in groups"
groups_updated: "Groups updated!"
save_groups: "Save groups"
select_groups: "Select groups…"
opt_in_groups: "Opt-in groups"
opt_in_groups_instructions: "Opt-in groups can be used to let a certain group of users to test a change before it is released to everyone. Note that these groups will be irrelevant once a change progresses to the Stable status."
toggled_too_fast: "You have toggled this change too quickly. Please wait a few seconds before trying again."
change_enabled: "You have opted-in to this change"
change_disabled: "You have opted-out of this change"
permanent_notice: "This change is now permanent and it will soon be removed. You are no longer able to opt-out."
permanent_no_group_selection: "N/A, permanent changes apply to all groups."
no_changes: "There are no upcoming changes at this time."
preview: "Preview"
statuses:
pre_alpha: "Pre-Alpha"
alpha: "Alpha"
beta: "Beta"
stable: "Stable"
permanent: "Permanent"
impact_roles:
admins: "Admins"
moderators: "Moderators"
staff: "Staff"
all_members: "All members"
developers: "Developers"
filter:
search_placeholder: "Filter by name, description, or plugin..."
no_results: "No upcoming changes match your filter"
all: "All"
enabled_all: "Enabled or disabled"
enabled: "Enabled"
disabled: "Disabled"
impact_type_all: "All impact types"
impact_type_feature: "Feature"
impact_type_other: "Other"
status_all: "All statuses"
status_pre_alpha: "Pre-Alpha"
status_alpha: "Alpha"
status_beta: "Beta"
status_stable: "Stable"
status_permanent: "Permanent"
config:
about:
title: "About your site"
@@ -5697,6 +5747,9 @@ en:
title: "Localization"
header_description: "Configure your communitys interface language and other localization options for your members"
keywords: "locale|language|timezone|unicode|ltr"
upcoming_changes:
title: "Upcoming changes"
header_description: "A list of upcoming changes to Discourse that may affect your site, and experimental features that you may opt-in to. You can filter by impact, change type, and status.\n\nOpt-in groups can be used to let a certain group of users to test a change before it is released to everyone, though these groups will be irrelevant once a change progresses to the Stable status."
login:
title: "Login & authentication"
header_description: "Configure how users log in and authenticate, secrets and keys, OAuth2 providers, and more"
@@ -7525,6 +7578,8 @@ en:
delete_associated_accounts: "delete associated accounts"
change_theme_site_setting: "change theme site setting"
stop_impersonating: "stop impersonating"
upcoming_change_toggled: "upcoming change toggled"
change_site_setting_groups: "change site setting groups"
screened_emails:
title: "Screened emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."
+10 -1
View File
@@ -1681,6 +1681,7 @@ en:
problem:
twitter_login: 'Twitter login appears to not be working at the moment. Check the credentials in <a href="%{base_path}/admin/site_settings/category/login?filter=twitter">the Site Settings</a>.'
group_email_credentials: 'There was an issue with the email credentials for the group <a href="%{base_path}/g/%{group_name}/manage/email">%{group_full_name}</a>. No emails will be sent from the group inbox until this problem is addressed. %{error}'
upcoming_change_stable_opted_out: 'Your site has opted-out of the "%{upcoming_change}" upcoming change. This change has now reached the "Stable" status and may soon be removed or made permanent. Visit <a href="%{base_path}/admin/config/upcoming-changes">the upcoming changes page</a> to review details about this change.'
rails_env: "Your server is running in %{env} mode."
host_names: "Your config/database.yml file is using the default localhost hostname. Update it to use your site's hostname."
sidekiq: 'Sidekiq is not running. Many tasks, like sending emails, are executed asynchronously by Sidekiq. Please ensure at least one Sidekiq process is running. <a href="https://github.com/mperham/sidekiq" target="_blank">Learn about Sidekiq here</a>.'
@@ -2766,12 +2767,14 @@ en:
view_raw_email_allowed_groups: "Groups which can view the raw email content of a post if it was created by an incoming email. This includes email headers and other technical information."
rich_editor: "Enable the rich editor for the composer so all users can switch between the current Markdown mode and the new rich text editor for more intuitive and user-friendly composition."
default_composition_mode: "Set the default mode for your community's composer. Rich text mode provides a more modern, familiar writing experience for most users, while Markdown mode may be suitable for more technical audiences. Members can use a toggle in the composer toolbar to switch between modes."
viewport_based_mobile_mode: "Use viewport width to determine mobile/desktop modes. This setting will be removed soon. If you find the need to turn it off, please let us know <a href='https://meta.discourse.org/t/384280'>on Meta</a>."
reviewable_ui_refresh: "Groups that can use the experimental new UI in the review queue."
viewport_based_mobile_mode: "Use viewport width to determine mobile/desktop modes. This setting will be removed soon. If you find the need to turn it off, please let us know <a href='https://meta.discourse.org/t/384280'>on Meta</a>."
content_localization_enabled: "Displays localized content for users based on their browser or user language preferences. Such content may include categories, tags, posts, and topics. Supported locales are set in 'content localization supported locales'."
content_localization_supported_locales: "List of supported locales that user content can be translated to. Requires 'content localization enabled'."
content_localization_allowed_groups: "Groups allowed to update localized content. Requires 'content localization enabled'."
content_localization_language_switcher: "Show a language switcher in the header, allowing visitors to switch between translated versions of Discourse and user-contributed content. Uses languages defined in 'content localization supported locales'"
enable_upcoming_changes: "Enable upcoming changes"
errors:
invalid_css_color: "Invalid color. Enter a color name or hex value."
@@ -5698,6 +5701,12 @@ en:
auto_deleted_hidden_posts: "Automatically destroyed hidden posts"
seed_data_topic_updated: "Seed data topic updated"
seed_data_topic_deleted: "Seed data topic deleted"
upcoming_changes:
log_manually_toggled: "Manually toggled by user"
log_promoted: >
This upcoming change was automatically enabled because it reached the '%{change_status}' status, which meets or exceeds the defined promotion status of '%{promotion_status_threshold}' on your site.
See the <a href="%{base_url}/admin/config/upcoming-changes">upcoming changes page</a> for more detail.
reviewables:
already_handled: "Thanks, but we've already reviewed that post and determined it does not need to be flagged again."
+3
View File
@@ -447,6 +447,9 @@ Discourse::Application.routes.draw do
put "/fonts" => "fonts#update"
get "colors/:id" => "color_palettes#show"
get "colors" => "color_palettes#index"
get "upcoming-changes" => "upcoming_changes#index"
put "upcoming-changes/groups" => "upcoming_changes#update_groups"
put "upcoming-changes/toggle" => "upcoming_changes#toggle_change"
resources :flags, only: %i[index new create update destroy] do
put "toggle"
+4
View File
@@ -4269,3 +4269,7 @@ experimental:
type: group_list
default: ""
area: "experimental"
enable_upcoming_changes:
default: false
hidden: true
client: true
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class AddSiteSettingGroups < ActiveRecord::Migration[8.0]
def change
create_table :site_setting_groups do |t|
t.string :name, null: false
t.string :group_ids, null: false
t.timestamps
end
add_index :site_setting_groups, :name, unique: true
end
end
@@ -0,0 +1,274 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { concat } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { modifier } from "ember-modifier";
import { eq, notEq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import GroupSelector from "discourse/components/group-selector";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import discourseLater from "discourse/lib/later";
import lightbox from "discourse/lib/lightbox";
import Group from "discourse/models/group";
import { i18n } from "discourse-i18n";
export default class UpcomingChangeItem extends Component {
@service toasts;
@service siteSettings;
@tracked toggleSettingDisabled = false;
@tracked bufferedGroups = this.args.change.groups;
registeredMenu = null;
applyLightbox = modifier((element) => lightbox(element, this.siteSettings));
impactRoleIcon(impactRole) {
switch (impactRole) {
case "admins":
return "shield-halved";
case "moderators":
return "shield-halved";
case "staff":
return "shield-halved";
case "all_members":
return "users";
case "developers":
return "code";
}
}
@action
groupFinder(term) {
return Group.findAll({ term, ignore_automatic: false });
}
@action
async saveGroups() {
try {
await ajax("/admin/config/upcoming-changes/groups", {
type: "PUT",
data: {
setting: this.args.change.setting,
group_names: this.args.change.groups.split(","),
},
});
this.bufferedGroups = this.args.change.groups;
this.toasts.success({
duration: "short",
data: {
message: i18n("admin.upcoming_changes.groups_updated"),
},
});
} catch (err) {
popupAjaxError(err);
}
}
@action
async toggleChange() {
if (this.toggleSettingDisabled) {
this.toasts.error({
duration: "short",
data: {
message: i18n("admin.upcoming_changes.toggled_too_fast"),
},
});
return;
}
this.args.change.value = !this.args.change.value;
this.toggleSettingDisabled = true;
discourseLater(() => {
this.toggleSettingDisabled = false;
}, 3000);
try {
await ajax("/admin/config/upcoming-changes/toggle", {
type: "PUT",
data: {
setting_name: this.args.change.setting,
},
});
this.toasts.success({
duration: "short",
data: {
message: this.args.change.value
? i18n("admin.upcoming_changes.change_enabled")
: i18n("admin.upcoming_changes.change_disabled"),
},
});
} catch (error) {
this.args.change.value = !this.args.change.value;
return popupAjaxError(error);
}
}
@bind
onRegisterMenuForRow(menuApi) {
this.registeredMenu = menuApi;
}
@action
groupsChanged(newGroups) {
this.args.change.groups = newGroups;
}
<template>
<tr
class="d-table__row upcoming-change-row"
data-upcoming-change={{@change.setting}}
>
<td class="d-table__cell --overview">
{{#if @change.plugin}}
<span class="upcoming-change__plugin">
{{icon "plug"}}
{{@change.plugin}}
</span>
{{/if}}
<div class="d-table__overview-name">
{{@change.humanized_name}}
</div>
{{#if @change.description}}
<div class="d-table__overview-about upcoming-change__description">
{{@change.description}}
<div
class="upcoming-change__description-details"
{{this.applyLightbox}}
>
{{#if @change.upcoming_change.image.url}}
<a
href={{@change.upcoming_change.image.url}}
class="lightbox upcoming-change__image-preview"
rel="nofollow ugc noopener"
data-target-width={{@change.upcoming_change.image.width}}
data-target-height={{@change.upcoming_change.image.height}}
data-large-src={{@change.upcoming_change.image.url}}
>{{icon "far-image"}}
{{i18n "admin.upcoming_changes.preview"}}</a>
{{/if}}
{{#if @change.upcoming_change.learn_more_url}}
<span class="upcoming-change__learn-more">
{{htmlSafe
(i18n
"learn_more_with_link"
url=@change.upcoming_change.learn_more_url
)
}}
</span>
{{/if}}
</div>
</div>
{{/if}}
{{#if (eq @change.upcoming_change.status "permanent")}}
<div class="upcoming-change__permanent-notice">
{{icon "triangle-exclamation"}}
{{i18n "admin.upcoming_changes.permanent_notice"}}
</div>
{{/if}}
<div class="upcoming-change__badges">
<span
title={{i18n
(concat
"admin.upcoming_changes.statuses."
@change.upcoming_change.status
)
}}
class={{concatClass
"upcoming-change__badge"
(concat "--status-" @change.upcoming_change.status)
}}
>
{{icon
(if
(eq @change.upcoming_change.status "permanent")
"lock"
"far-circle-dot"
)
}}
{{i18n
(concat
"admin.upcoming_changes.statuses."
@change.upcoming_change.status
)
}}
</span>
<span
title={{i18n
(concat
"admin.upcoming_changes.impact_roles."
@change.upcoming_change.impact_role
)
}}
class={{concatClass
"upcoming-change__badge"
(concat "--impact-role-" @change.upcoming_change.impact_role)
}}
>
{{icon (this.impactRoleIcon @change.upcoming_change.impact_role)}}
{{i18n
(concat
"admin.upcoming_changes.impact_roles."
@change.upcoming_change.impact_role
)
}}
</span>
</div>
</td>
<td class="d-table__cell --detail upcoming-change__groups">
<div class="d-table__mobile-label">
{{i18n "admin.upcoming_changes.opt_in_groups"}}
</div>
{{#if (eq @change.upcoming_change.status "permanent")}}
{{i18n "admin.upcoming_changes.permanent_no_group_selection"}}
{{else}}
<GroupSelector
@groupFinder={{this.groupFinder}}
@groupNames={{@change.groups}}
@onChange={{this.groupsChanged}}
@placeholderKey="admin.upcoming_changes.select_groups"
/>
{{/if}}
{{#if (notEq @change.groups this.bufferedGroups)}}
<DButton
class="upcoming-change__save-groups btn-primary"
@icon="check"
@size="small"
@title="admin.upcoming_changes.save_groups"
{{on "click" this.saveGroups}}
/>
{{/if}}
</td>
<td class="d-table__cell --detail upcoming-change__toggle-cell">
<div class="d-table__mobile-label">
{{i18n "admin.upcoming_changes.enabled"}}
</div>
<DToggleSwitch
@state={{@change.value}}
class="upcoming-change__toggle"
{{on "click" this.toggleChange}}
disabled={{eq @change.upcoming_change.status "permanent"}}
/>
</td>
</tr>
</template>
}
@@ -0,0 +1,134 @@
import Component from "@glimmer/component";
import { array } from "@ember/helper";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
import { i18n } from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import UpcomingChangeItem from "admin/components/admin-config-areas/upcoming-change-item";
import AdminFilterControls from "admin/components/admin-filter-controls";
export default class AdminConfigAreasUpcomingChanges extends Component {
get upcomingChanges() {
return this.args.upcomingChanges.map((change) => {
return new TrackedObject(change);
});
}
get dropdownOptions() {
return {
status: [
{
label: i18n("admin.upcoming_changes.filter.status_all"),
value: "all",
filterFn: () => true,
},
{
label: i18n("admin.upcoming_changes.filter.status_pre_alpha"),
value: "pre_alpha",
filterFn: (change) => change.upcoming_change.status === "pre_alpha",
},
{
label: i18n("admin.upcoming_changes.filter.status_alpha"),
value: "alpha",
filterFn: (change) => change.upcoming_change.status === "alpha",
},
{
label: i18n("admin.upcoming_changes.filter.status_beta"),
value: "beta",
filterFn: (change) => change.upcoming_change.status === "beta",
},
{
label: i18n("admin.upcoming_changes.filter.status_stable"),
value: "stable",
filterFn: (change) => change.upcoming_change.status === "stable",
},
{
label: i18n("admin.upcoming_changes.filter.status_permanent"),
value: "permanent",
filterFn: (change) => change.upcoming_change.status === "permanent",
},
],
type: [
{
label: i18n("admin.upcoming_changes.filter.impact_type_all"),
value: "all",
filterFn: () => true,
},
{
label: i18n("admin.upcoming_changes.filter.impact_type_feature"),
value: "feature",
filterFn: (change) =>
change.upcoming_change.impact_type === "feature",
},
{
label: i18n("admin.upcoming_changes.filter.impact_type_other"),
value: "other",
filterFn: (change) => change.upcoming_change.impact_type === "other",
},
],
enabled: [
{
label: i18n("admin.upcoming_changes.filter.enabled_all"),
value: "all",
filterFn: () => true,
},
{
label: i18n("admin.upcoming_changes.filter.enabled"),
value: "enabled",
filterFn: (change) => change.value,
},
{
label: i18n("admin.upcoming_changes.filter.disabled"),
value: "disabled",
filterFn: (change) => !change.value,
},
],
};
}
<template>
<AdminFilterControls
@array={{this.upcomingChanges}}
@searchableProps={{array
"humanized_name"
"description"
"plugin_identifier"
}}
@dropdownOptions={{this.dropdownOptions}}
@inputPlaceholder={{i18n
"admin.upcoming_changes.filter.search_placeholder"
}}
@noResultsMessage={{i18n
"admin.upcoming_changes.filter.search_placeholder"
}}
>
<:content as |upcomingChanges|>
<table class="d-table upcoming-changes-table">
<thead class="d-table__header">
<tr class="d-table__row">
<th
class="d-table__header-cell upcoming-change__name-header"
>{{i18n "admin.upcoming_changes.name"}}</th>
<th
class="d-table__header-cell upcoming-change__groups-header"
>{{i18n "admin.upcoming_changes.opt_in_groups"}}</th>
<th
class="d-table__header-cell upcoming-change__enabled-header"
>{{i18n "admin.plugins.enabled"}}</th>
</tr>
</thead>
<tbody class="d-table__body">
{{#each upcomingChanges as |change|}}
<UpcomingChangeItem @change={{change}} />
{{/each}}
</tbody>
</table>
</:content>
</AdminFilterControls>
{{#unless this.upcomingChanges}}
<AdminConfigAreaEmptyList
@emptyLabel="admin.upcoming_changes.no_changes"
/>
{{/unless}}
</template>
}
@@ -1,13 +1,16 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { fn, get, hash } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { schedule } from "@ember/runloop";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
import { and, not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DSelect from "discourse/components/d-select";
import FilterInput from "discourse/components/filter-input";
import concatClass from "discourse/helpers/concat-class";
import { isTesting } from "discourse/lib/environment";
/**
* admin filter controls component that support both client-side and server-side filtering
@@ -17,21 +20,38 @@ import FilterInput from "discourse/components/filter-input";
*
* @component AdminFilterControls
* @param {Array} array - The dataset to display
* @param {Array} [searchableProps] - Property names to search for client-side text filtering
* @param {Array} [dropdownOptions] - Dropdown options. Format: [{value, label, filterFn?}]
* @param {Array} [searchableProps] - Property names to search for client-side text filtering, can be dot-separated
* for nested properties (e.g. "user.name")
* @param {Array|Object} [dropdownOptions] - Dropdown options. Format: [{value, label, filterFn?}]. Or, if you
* want multiple dropdowns, format is: { dropdown1: [...], dropdown2: [...] }
* @param {String} [inputPlaceholder] - Placeholder text for search input
* @param {String} [defaultDropdown="all"] - Default dropdown value
* @param {String|Object} [defaultDropdownValue="all"] - Default dropdown value(s). For single dropdown: "all",
* for multiple: { dropdown1: "all", dropdown2: "all" }
* @param {String} [noResultsMessage] - Message shown when no results found
* @param {Boolean} [loading] - Whether data is loading (hides reset button during loading)
* @param {Number} [minItemsForFilter] - Minimum items before showing filters (default: always show)
* @param {Function} [onTextFilterChange] - Callback for text changes (enables server-side mode)
* @param {Function} [onDropdownFilterChange] - Callback for dropdown changes (enables server-side mode)
* @param {Function} [onDropdownFilterChange] - Callback for dropdown changes (enables server-side mode).
* For multiple dropdowns: receives (key, value)
* @param {Function} [onResetFilters] - Callback for reset action (server-side mode)
*/
export default class AdminFilterControls extends Component {
@tracked textFilter = "";
@tracked dropdownFilter = "all";
@tracked dropdownFilters = new TrackedObject();
@tracked
showFilterDropdowns = this.args.filterDropdownsExpanded ?? isTesting();
constructor() {
super(...arguments);
if (this.hasMultipleDropdowns) {
Object.keys(this.dropdownOptions).forEach((key) => {
this.dropdownFilters[key] = this.defaultValue(key);
});
}
}
get array() {
return Array.isArray(this.args.array) ? this.args.array : [];
@@ -43,18 +63,32 @@ export default class AdminFilterControls extends Component {
: [];
}
get hasMultipleDropdowns() {
return (
this.dropdownOptions &&
!Array.isArray(this.dropdownOptions) &&
typeof this.dropdownOptions === "object"
);
}
get dropdownOptions() {
if (!this.args.dropdownOptions) {
return [];
}
return Array.isArray(this.args.dropdownOptions)
? this.args.dropdownOptions
: [];
: this.args.dropdownOptions;
}
get showDropdownFilter() {
return this.dropdownOptions.length > 1;
return (
this.dropdownOptions.length > 1 ||
(this.hasMultipleDropdowns && this.showFilterDropdowns)
);
}
get defaultDropdown() {
return this.args.defaultDropdown || "all";
get defaultDropdownValue() {
return this.args.defaultDropdownValue || "all";
}
get showFilters() {
@@ -64,9 +98,17 @@ export default class AdminFilterControls extends Component {
}
get hasActiveFilters() {
return (
this.textFilter.length > 0 || this.dropdownFilter !== this.defaultDropdown
);
if (this.textFilter.length > 0) {
return true;
}
if (this.hasMultipleDropdowns) {
return Object.keys(this.dropdownFilters).some((key) => {
return this.dropdownFilters[key] !== this.defaultValue(key);
});
}
return this.dropdownFilter !== this.defaultDropdownValue;
}
get filteredData() {
@@ -89,7 +131,18 @@ export default class AdminFilterControls extends Component {
});
}
if (this.dropdownFilter !== this.defaultDropdown) {
if (this.hasMultipleDropdowns) {
Object.keys(this.dropdownFilters).forEach((key) => {
const selectedValue = this.dropdownFilters[key];
const options = this.dropdownOptions[key] || [];
const selectedOption = options.find(
(option) => option.value === selectedValue
);
if (selectedOption?.filterFn) {
filtered = filtered.filter(selectedOption.filterFn);
}
});
} else if (this.dropdownFilter !== this.defaultDropdownValue) {
const selectedOption = this.dropdownOptions.find(
(option) => option.value === this.dropdownFilter
);
@@ -102,7 +155,9 @@ export default class AdminFilterControls extends Component {
}
/**
* get nested value
* Allows searchable props in the format user.name, this function gets the
* nested value based on a dot-separated path.
*
* @param {Object} obj - The object to get value from
* @param {String} path - The property path (e.g. "user.name")
* @returns {*} The value at the path
@@ -111,9 +166,23 @@ export default class AdminFilterControls extends Component {
return path.split(".").reduce((current, key) => current?.[key], obj);
}
defaultValue(key) {
const defaults =
typeof this.defaultDropdownValue === "object"
? this.defaultDropdownValue
: {};
return defaults[key] || "all";
}
@action
setupComponent() {
this.dropdownFilter = this.defaultDropdown;
if (this.hasMultipleDropdowns) {
Object.keys(this.dropdownOptions).forEach((key) => {
this.dropdownFilters[key] = this.defaultValue(key);
});
} else {
this.dropdownFilter = this.defaultDropdownValue;
}
}
@action
@@ -124,16 +193,27 @@ export default class AdminFilterControls extends Component {
}
@action
onDropdownFilterChange(value) {
this.dropdownFilter = value;
this.args.onDropdownFilterChange?.(value);
onDropdownFilterChange(keyOrValue, value) {
if (this.hasMultipleDropdowns) {
this.dropdownFilters[keyOrValue] = value;
this.args.onDropdownFilterChange?.(keyOrValue, value);
} else {
this.dropdownFilter = keyOrValue;
this.args.onDropdownFilterChange?.(keyOrValue);
}
}
@action
resetFilters() {
this.textFilter = "";
this.dropdownFilter = this.defaultDropdown;
if (this.hasMultipleDropdowns) {
Object.keys(this.dropdownFilters).forEach((key) => {
this.dropdownFilters[key] = this.defaultValue(key);
});
} else {
this.dropdownFilter = this.defaultDropdownValue;
}
if (this.args.onResetFilters) {
this.args.onResetFilters();
@@ -144,31 +224,74 @@ export default class AdminFilterControls extends Component {
});
}
@action
toggleFilters() {
this.showFilterDropdowns = !this.showFilterDropdowns;
}
<template>
{{#if this.showFilters}}
<div class="admin-filter-controls" {{didInsert this.setupComponent}}>
<FilterInput
placeholder={{@inputPlaceholder}}
@filterAction={{this.onTextFilterChange}}
@value={{this.textFilter}}
class="admin-filter-controls__input"
@icons={{hash left="magnifying-glass"}}
/>
<div
class={{concatClass
"admin-filter-controls"
(if this.hasMultipleDropdowns "--multiple-dropdowns")
}}
{{didInsert this.setupComponent}}
>
<div class="admin-filter-controls__inputs">
<FilterInput
placeholder={{@inputPlaceholder}}
@filterAction={{this.onTextFilterChange}}
@value={{this.textFilter}}
class="admin-filter-controls__input"
@icons={{hash left="magnifying-glass"}}
/>
{{#if this.hasMultipleDropdowns}}
<DButton
class="btn-transparent admin-filter-controls__toggle-filters"
@icon="filter"
@title="toggle_filters"
@action={{this.toggleFilters}}
/>
{{/if}}
</div>
{{#if this.showDropdownFilter}}
<DSelect
@value={{this.dropdownFilter}}
@includeNone={{false}}
@onChange={{this.onDropdownFilterChange}}
class="admin-filter-controls__dropdown"
as |select|
>
{{#each this.dropdownOptions as |option|}}
<select.Option @value={{option.value}}>
{{option.label}}
</select.Option>
{{/each}}
</DSelect>
<div class="admin-filter-controls__dropdowns">
{{#if this.hasMultipleDropdowns}}
{{#each-in this.dropdownOptions as |key options|}}
<DSelect
@value={{get this.dropdownFilters key}}
@includeNone={{false}}
@onChange={{fn this.onDropdownFilterChange key}}
class="admin-filter-controls__dropdown admin-filter-controls__dropdown--{{key}}"
data-dropdown-key={{key}}
as |select|
>
{{#each options as |option|}}
<select.Option @value={{option.value}}>
{{option.label}}
</select.Option>
{{/each}}
</DSelect>
{{/each-in}}
{{else}}
<DSelect
@value={{this.dropdownFilter}}
@includeNone={{false}}
@onChange={{this.onDropdownFilterChange}}
class="admin-filter-controls__dropdown"
as |select|
>
{{#each this.dropdownOptions as |option|}}
<select.Option @value={{option.value}}>
{{option.label}}
</select.Option>
{{/each}}
</DSelect>
{{/if}}
</div>
{{/if}}
{{yield to="actions"}}
@@ -176,6 +299,7 @@ export default class AdminFilterControls extends Component {
{{/if}}
{{yield to="aboveContent"}}
{{#if this.filteredData.length}}
{{yield this.filteredData to="content"}}
{{else if this.showFilters}}
@@ -186,7 +310,7 @@ export default class AdminFilterControls extends Component {
{{/if}}
<DButton
@icon="arrow-rotate-left"
@label="admin.plugins.filters.reset"
@label="reset_filter"
@action={{this.resetFilters}}
class="btn-default admin-filter-controls__reset"
/>
@@ -176,6 +176,15 @@ export default class AdminLogsStaffActionLogsController extends Controller {
});
}
@action
showHtmlSafeContext(model) {
if (model.action_name === "upcoming_change_toggled") {
return true;
}
return false;
}
@action
showCustomDetailsModal(model, event) {
event?.preventDefault();
@@ -0,0 +1,15 @@
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
export default class AdminConfigUpcomingChangesRoute extends DiscourseRoute {
titleToken() {
return i18n("admin.config.upcoming_changes.title");
}
model() {
return ajax("/admin/config/upcoming-changes").then(
(result) => result.upcoming_changes
);
}
}
@@ -238,6 +238,8 @@ export default function () {
});
this.route("about");
this.route("upcomingChanges", { path: "/upcoming-changes" });
this.route("login", { path: "/login-and-authentication" }, function () {
this.route("settings", {
path: "/",
@@ -0,0 +1,26 @@
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DPageHeader from "discourse/components/d-page-header";
import { i18n } from "discourse-i18n";
import AdminConfigAreasUpcomingChanges from "admin/components/admin-config-areas/upcoming-changes";
export default <template>
<DPageHeader
@hideTabs={{true}}
@titleLabel={{i18n "admin.config.upcoming_changes.title"}}
@descriptionLabel={{i18n
"admin.config.upcoming_changes.header_description"
}}
>
<:breadcrumbs>
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@path="/admin/config/upcoming-changes"
@label={{i18n "admin.config.upcoming_changes.title"}}
/>
</:breadcrumbs>
</DPageHeader>
<div class="admin-config-page__main-area">
<AdminConfigAreasUpcomingChanges @upcomingChanges={{@controller.model}} />
</div>
</template>
@@ -202,7 +202,13 @@ export default <template>
{{/if}}
</div>
</td>
<td class="col value context">{{item.context}}</td>
<td class="col value context">
{{#if (fn @controller.showHtmlSafeContext item)}}
{{htmlSafe item.context}}
{{else}}
{{item.context}}
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
@@ -398,6 +398,19 @@ export default class AdminSidebarPanel extends BaseCustomSidebarPanel {
]);
}
if (siteSettings.enable_upcoming_changes) {
this.adminNavManager.amendLinksToSection("root", [
{
name: "admin_upcoming_changes",
route: "adminConfig.upcomingChanges",
label: "admin.config.upcoming_changes.title",
description: "admin.config.upcoming_changes.header_description",
icon: "flask",
keywords: "admin.config.upcoming_changes.keywords",
},
]);
}
for (const [sectionName, additionalLinks] of Object.entries(
additionalAdminSidebarSectionLinks
)) {
@@ -5,7 +5,8 @@ import i18n from "discourse-i18n";
export function createSiteSettingsFromPreloaded(
siteSettings,
themeSiteSettingOverrides
themeSiteSettingOverrides,
currentUser
) {
const settings = new TrackedObject(siteSettings);
@@ -13,9 +14,28 @@ export function createSiteSettingsFromPreloaded(
for (const [key, value] of Object.entries(themeSiteSettingOverrides)) {
settings[key] = value;
}
// eslint-disable-next-line no-console
console.debug(
"[SiteSettings] Overriding site settings with theme overrides:",
themeSiteSettingOverrides
);
settings.themeSiteSettingOverrides = themeSiteSettingOverrides;
}
if (currentUser?.upcoming_changes) {
for (const [key, value] of Object.entries(currentUser.upcoming_changes)) {
settings[key] = value;
}
// eslint-disable-next-line no-console
console.debug(
"[SiteSettings] Overriding site settings with upcoming changes based on user group permissions:",
currentUser.upcoming_changes
);
settings.currentUserUpcomingChanges = currentUser.upcoming_changes;
}
// localize locale names here as they are not localized in the backend
// due to initialization order and caching
if (settings.available_locales) {
@@ -55,7 +75,8 @@ export default class SiteSettingsService {
static create() {
return createSiteSettingsFromPreloaded(
PreloadStore.get("siteSettings"),
PreloadStore.get("themeSiteSettingOverrides")
PreloadStore.get("themeSiteSettingOverrides"),
PreloadStore.get("currentUser")
);
}
}
@@ -0,0 +1,766 @@
import { click, fillIn, render, select } from "@ember/test-helpers";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import AdminFilterControls from "admin/components/admin-filter-controls";
const SAMPLE_DATA = [
{
id: 1,
name: "First Item",
description: "This is the first item",
category: "feature",
enabled: true,
},
{
id: 2,
name: "Second Item",
description: "This is the second item",
category: "other",
enabled: false,
},
{
id: 3,
name: "Third Item",
description: "This is the third item",
category: "feature",
enabled: true,
},
];
const SAMPLE_DROPDOWN_OPTIONS = [
{ label: "All", value: "all", filterFn: () => true },
{
label: "Feature",
value: "feature",
filterFn: (item) => item.category === "feature",
},
{
label: "Other",
value: "other",
filterFn: (item) => item.category === "other",
},
];
module("Integration | Component | AdminFilterControls", function (hooks) {
setupRenderingTest(hooks);
test("renders text filter input", async function (assert) {
this.set("data", SAMPLE_DATA);
await render(
<template>
<AdminFilterControls @array={{this.data}} @inputPlaceholder="Search...">
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert
.dom(".admin-filter-controls__input")
.exists("renders text filter input");
assert
.dom(".filter-input")
.hasAttribute("placeholder", "Search...", "has correct placeholder");
});
test("filters data by text (client-side)", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("searchableProps", ["name", "description"]);
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@searchableProps={{this.searchableProps}}
@inputPlaceholder="Search..."
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert.dom(".item").exists({ count: 3 }, "shows all items initially");
await fillIn(".filter-input", "first");
assert
.dom(".item")
.exists({ count: 1 }, "shows only matching items after filtering");
assert.dom(".item[data-id='1']").exists("shows the correct filtered item");
});
test("renders single dropdown filter", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("dropdownOptions", SAMPLE_DROPDOWN_OPTIONS);
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@dropdownOptions={{this.dropdownOptions}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert
.dom(".admin-filter-controls__dropdown")
.exists("renders dropdown filter");
});
test("filters data by single dropdown (client-side)", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("dropdownOptions", SAMPLE_DROPDOWN_OPTIONS);
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@dropdownOptions={{this.dropdownOptions}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert.dom(".item").exists({ count: 3 }, "shows all items initially");
await select(".admin-filter-controls__dropdown", "feature");
assert
.dom(".item")
.exists(
{ count: 2 },
"shows only feature items after dropdown selection"
);
assert.dom(".item[data-id='1']").exists("shows first feature item");
assert.dom(".item[data-id='3']").exists("shows second feature item");
});
test("combines text and dropdown filters (client-side)", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("searchableProps", ["name", "description"]);
this.set("dropdownOptions", SAMPLE_DROPDOWN_OPTIONS);
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@searchableProps={{this.searchableProps}}
@dropdownOptions={{this.dropdownOptions}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert.dom(".item").exists({ count: 3 }, "shows all items initially");
await select(".admin-filter-controls__dropdown", "feature");
assert
.dom(".item")
.exists({ count: 2 }, "shows only feature items after dropdown");
await fillIn(".filter-input", "third");
assert
.dom(".item")
.exists({ count: 1 }, "shows only items matching both text and dropdown");
assert.dom(".item[data-id='3']").exists("shows the correct item");
});
test("shows reset button when filters are active", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("searchableProps", ["name"]);
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@searchableProps={{this.searchableProps}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert
.dom(".admin-filter-controls__reset")
.doesNotExist("no reset button initially");
await fillIn(".filter-input", "nonexistent");
assert
.dom(".admin-filter-controls__no-results")
.exists("shows no results message");
assert
.dom(".admin-filter-controls__reset")
.exists("shows reset button after filtering");
});
test("reset button clears filters", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("searchableProps", ["name"]);
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@searchableProps={{this.searchableProps}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
await fillIn(".filter-input", "first");
assert.dom(".item").exists({ count: 1 }, "shows filtered results");
await fillIn(".filter-input", "firstblah");
assert
.dom(".item")
.doesNotExist("does not show any results when filters find none");
await click(".admin-filter-controls__reset");
assert.dom(".item").exists({ count: 3 }, "shows all items after reset");
assert.dom(".filter-input").hasValue("", "clears text input");
});
test("respects minItemsForFilter parameter", async function (assert) {
this.set("data", [SAMPLE_DATA[0]]);
await render(
<template>
<AdminFilterControls @array={{this.data}} @minItemsForFilter={{2}}>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert
.dom(".admin-filter-controls")
.doesNotExist("hides filters when items below minimum");
assert
.dom(".item")
.exists({ count: 1 }, "still shows content even when filters are hidden");
});
test("calls onTextFilterChange callback for server-side filtering", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("textFilterCallback", (event) => {
assert.step(`text-filter:${event.target.value}`);
});
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@onTextFilterChange={{this.textFilterCallback}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
await fillIn(".filter-input", "test");
assert.verifySteps(["text-filter:test"], "calls callback with value");
});
test("calls onDropdownFilterChange callback for server-side filtering", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("dropdownOptions", [
{ label: "All", value: "all" },
{ label: "Feature", value: "feature" },
]);
this.set("dropdownFilterCallback", (value) => {
assert.step(`dropdown-filter:${value}`);
});
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@dropdownOptions={{this.dropdownOptions}}
@onDropdownFilterChange={{this.dropdownFilterCallback}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
await select(".admin-filter-controls__dropdown", "feature");
assert.verifySteps(
["dropdown-filter:feature"],
"calls callback with selected value"
);
});
test("calls onResetFilters callback for server-side filtering", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("searchableProps", ["name"]);
this.set("resetCallback", () => {
assert.step("reset-filters");
});
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@searchableProps={{this.searchableProps}}
@onResetFilters={{this.resetCallback}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
await fillIn(".filter-input", "test");
await click(".admin-filter-controls__reset");
assert.verifySteps(["reset-filters"], "calls reset callback");
});
test("skips client-side filtering when server callbacks provided", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("searchableProps", ["name"]);
this.set("textFilterCallback", () => {});
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@searchableProps={{this.searchableProps}}
@onTextFilterChange={{this.textFilterCallback}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
await fillIn(".filter-input", "first");
assert
.dom(".item")
.exists(
{ count: 3 },
"does not filter client-side when callback provided"
);
});
test("yields to actions block", async function (assert) {
this.set("data", SAMPLE_DATA);
await render(
<template>
<AdminFilterControls @array={{this.data}}>
<:actions>
<button type="button" class="custom-action">Custom Action</button>
</:actions>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item">{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert
.dom(".custom-action")
.exists("renders custom actions in actions block");
});
test("yields to aboveContent block", async function (assert) {
this.set("data", SAMPLE_DATA);
await render(
<template>
<AdminFilterControls @array={{this.data}}>
<:aboveContent>
<div class="above-content">Above Content Area</div>
</:aboveContent>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item">{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert
.dom(".above-content")
.exists("renders content in aboveContent block");
});
test("shows custom no results message", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("searchableProps", ["name"]);
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@searchableProps={{this.searchableProps}}
@noResultsMessage="No items found matching your criteria"
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item">{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
await fillIn(".filter-input", "nonexistent");
assert
.dom(".admin-filter-controls__no-results p")
.hasText(
"No items found matching your criteria",
"shows custom no results message"
);
});
test("does not show dropdown filter when only one option", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("dropdownOptions", [{ label: "All", value: "all" }]);
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@dropdownOptions={{this.dropdownOptions}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item">{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert
.dom(".admin-filter-controls__dropdown")
.doesNotExist("hides dropdown when only one option");
});
test("renders multiple dropdowns", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("dropdownOptions", {
category: [
{ label: "All", value: "all" },
{ label: "Feature", value: "feature" },
],
enabled: [
{ label: "All", value: "all" },
{ label: "Enabled", value: "enabled" },
],
});
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@dropdownOptions={{this.dropdownOptions}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item">{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert
.dom(".admin-filter-controls__dropdown")
.exists({ count: 2 }, "renders two dropdowns");
assert
.dom(".admin-filter-controls__dropdown--category")
.exists("renders category dropdown");
assert
.dom(".admin-filter-controls__dropdown--enabled")
.exists("renders enabled dropdown");
});
test("filters data by multiple dropdowns (client-side)", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("dropdownOptions", {
category: [
{ label: "All", value: "all", filterFn: () => true },
{
label: "Feature",
value: "feature",
filterFn: (item) => item.category === "feature",
},
],
enabled: [
{ label: "All", value: "all", filterFn: () => true },
{
label: "Enabled",
value: "enabled",
filterFn: (item) => item.enabled,
},
],
});
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@dropdownOptions={{this.dropdownOptions}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert.dom(".item").exists({ count: 3 }, "shows all items initially");
await select(".admin-filter-controls__dropdown--category", "feature");
assert
.dom(".item")
.exists({ count: 2 }, "shows only feature items after category filter");
await select(".admin-filter-controls__dropdown--enabled", "enabled");
assert
.dom(".item")
.exists(
{ count: 2 },
"shows only enabled feature items after both filters"
);
assert.dom(".item[data-id='1']").exists("shows first enabled feature");
assert.dom(".item[data-id='3']").exists("shows second enabled feature");
});
test("resets multiple dropdowns correctly", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("searchableProps", ["name"]);
this.set("dropdownOptions", {
category: [
{ label: "All", value: "all", filterFn: () => true },
{
label: "Feature",
value: "feature",
filterFn: (item) => item.category === "feature",
},
],
});
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@searchableProps={{this.searchableProps}}
@dropdownOptions={{this.dropdownOptions}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
await select(".admin-filter-controls__dropdown--category", "feature");
await fillIn(".filter-input", "first");
assert.dom(".item").exists({ count: 1 }, "shows filtered results");
await fillIn(".filter-input", "firstblah");
await click(".admin-filter-controls__reset");
assert.dom(".item").exists({ count: 3 }, "shows all items after reset");
});
test("calls onDropdownFilterChange with key and value for multiple dropdowns", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("dropdownOptions", {
category: [
{ label: "All", value: "all" },
{ label: "Feature", value: "feature" },
],
enabled: [
{ label: "All", value: "all" },
{ label: "Enabled", value: "enabled" },
],
});
this.set("dropdownFilterCallback", (key, value) => {
assert.step(`dropdown-filter:${key}:${value}`);
});
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@dropdownOptions={{this.dropdownOptions}}
@onDropdownFilterChange={{this.dropdownFilterCallback}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item">{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
await select(".admin-filter-controls__dropdown--category", "feature");
await select(".admin-filter-controls__dropdown--enabled", "enabled");
assert.verifySteps(
["dropdown-filter:category:feature", "dropdown-filter:enabled:enabled"],
"calls callback with key and value for each dropdown"
);
});
test("supports custom default values for multiple dropdowns", async function (assert) {
this.set("data", SAMPLE_DATA);
this.set("dropdownOptions", {
category: [
{ label: "All", value: "all", filterFn: () => true },
{
label: "Feature",
value: "feature",
filterFn: (item) => item.category === "feature",
},
],
});
this.set("defaultDropdownValue", { category: "feature" });
await render(
<template>
<AdminFilterControls
@array={{this.data}}
@dropdownOptions={{this.dropdownOptions}}
@defaultDropdownValue={{this.defaultDropdownValue}}
>
<:content as |filteredData|>
<div class="results">
{{#each filteredData as |item|}}
<div class="item" data-id={{item.id}}>{{item.name}}</div>
{{/each}}
</div>
</:content>
</AdminFilterControls>
</template>
);
assert
.dom(".item")
.exists(
{ count: 2 },
"shows only feature items initially because of defaultDropdownValue"
);
await select(".admin-filter-controls__dropdown--category", "all");
assert
.dom(".item")
.exists({ count: 3 }, "shows all items after changing filter");
});
});
+1 -2
View File
@@ -610,8 +610,7 @@ class Guardian
end
def can_see_reviewable_ui_refresh?
SiteSetting.reviewable_ui_refresh_map.include?(Group::AUTO_GROUPS[:everyone]) ||
@user.in_any_groups?(SiteSetting.reviewable_ui_refresh_map)
@user.in_any_groups?(SiteSetting.reviewable_ui_refresh_map)
end
def is_me?(other)
+98 -14
View File
@@ -90,6 +90,11 @@ module SiteSettingExtension
@humanized_names[name] ||= humanized_name(name)
end
def site_setting_group_ids
@site_setting_group_ids ||= {}
@site_setting_group_ids[provider.current_site] ||= {}
end
def defaults
@defaults ||= SiteSettings::DefaultsProvider.new(self)
end
@@ -122,6 +127,19 @@ module SiteSettingExtension
@requires_confirmation_settings ||= {}
end
# Valid upcoming change metadata looks like this
# in site_settings.yml:
#
# setting_name:
# setting_options...
# upcoming_change:
# status: "alpha" (see UpcomingChanges.statuses.keys)
# impact: "feature,staff" (feature|other for the first part, staff|admins|moderators|all_members|developers for the second part)
# learn_more_url: ""
def upcoming_change_metadata
@upcoming_change_metadata ||= {}
end
def hidden_settings_provider
@hidden_settings_provider ||= SiteSettings::HiddenProvider.new
end
@@ -210,6 +228,10 @@ module SiteSettingExtension
themeable.select { |_, value| value }.keys.sort
end
def upcoming_change_site_settings
upcoming_change_metadata.keys.sort
end
def client_settings_json
key = SiteSettingExtension.client_settings_cache_key
json = Discourse.cache.fetch(key, expires_in: 30.minutes) { client_settings_json_uncached }
@@ -282,6 +304,7 @@ module SiteSettingExtension
include_locale_setting: true,
only_overridden: false,
basic_attributes: false,
only_upcoming_changes: false,
filter_categories: nil,
filter_plugin: nil,
filter_names: nil,
@@ -344,6 +367,13 @@ module SiteSettingExtension
true
end
end
.select do |setting_name, _|
if only_upcoming_changes
upcoming_change_metadata.key?(setting_name)
else
true
end
end
.map do |s, v|
type_hash = type_supervisor.type_hash(s)
default = defaults.get(s, default_locale).to_s
@@ -378,6 +408,7 @@ module SiteSettingExtension
placeholder: placeholder(s),
mandatory_values: mandatory_values[s],
requires_confirmation: requires_confirmation_settings[s],
upcoming_change: only_upcoming_changes ? upcoming_change_metadata[s] : nil,
themeable: themeable[s],
)
opts.merge!(type_hash)
@@ -440,6 +471,8 @@ module SiteSettingExtension
)
]
refresh_site_setting_group_ids!
defaults_view = defaults.all(new_hash[:default_locale])
# add locale default and defaults based on default_locale, cause they are cached
@@ -455,18 +488,7 @@ module SiteSettingExtension
uploads.clear
end
if refresh_theme_site_settings
new_theme_site_settings = ThemeSiteSetting.generate_theme_map
theme_site_setting_changes, theme_site_setting_deletions =
diff_hash(new_theme_site_settings, theme_site_settings)
theme_site_setting_changes.each do |theme_id, settings|
theme_site_settings[theme_id] ||= {}
theme_site_settings[theme_id].merge!(settings)
end
theme_site_setting_deletions.each { |theme_id, _| theme_site_settings.delete(theme_id) }
end
refresh_theme_site_settings! if refresh_theme_site_settings
clear_cache!(
expire_theme_site_setting_cache:
@@ -475,7 +497,31 @@ module SiteSettingExtension
end
end
def refresh_site_setting_group_ids!
new_site_setting_group_ids_hash = SiteSettingGroup.generate_setting_group_map
site_setting_group_id_changes, site_setting_group_id_deletions =
diff_hash(new_site_setting_group_ids_hash, site_setting_group_ids)
site_setting_group_id_changes.each { |name, val| site_setting_group_ids[name] = val }
site_setting_group_id_deletions.each { |name, _| site_setting_group_ids.delete(name) }
end
def refresh_theme_site_settings!
new_theme_site_settings = ThemeSiteSetting.generate_theme_map
theme_site_setting_changes, theme_site_setting_deletions =
diff_hash(new_theme_site_settings, theme_site_settings)
theme_site_setting_changes.each do |theme_id, settings|
theme_site_settings[theme_id] ||= {}
theme_site_settings[theme_id].merge!(settings)
end
theme_site_setting_deletions.each { |theme_id, _| theme_site_settings.delete(theme_id) }
end
SITE_SETTINGS_CHANNEL = "/site_settings"
CLIENT_SETTINGS_CHANNEL = "/client_settings"
def ensure_listen_for_changes
return if @listen_for_changes == false
@@ -636,13 +682,33 @@ module SiteSettingExtension
DiscourseEvent.trigger(:theme_site_setting_changed, name, old_val, val)
end
# NOTE: This will not refresh the current process' site settings, only other processes
# that are listening for changes. We check if the current process_id is != to the message
# process ID before refreshing in process_message.
#
# If you need to refresh the current process as well, call refresh! (or another
# method to update caches) directly.
def notify_changed!
MessageBus.publish("/site_settings", process: process_id)
MessageBus.publish(SITE_SETTINGS_CHANNEL, process: process_id)
end
def notify_clients!(name, scoped_to = nil)
# Group-based upcoming changes cannot update clients, because we need
# to know a user to determine if the change is active for them.
#
# This is the same limitation that group-based site settings have --
# we cannot determine the full groups of a user on the client side,
# so we only use these in the CurrentUserSerializer to send down an
# attribute. Users will get the new value on page reload.
#
# If the upcoming change is not group-based then it's safe to just
# use the underlying site setting value.
if upcoming_change_site_settings.include?(name.to_sym) && UpcomingChanges.has_groups?(name)
return
end
MessageBus.publish(
"/client_settings",
CLIENT_SETTINGS_CHANNEL,
name: name,
# default_locale is a special case, it is not themeable and we define
# a custom getter for it, so we can just use the normal getter
@@ -892,6 +958,16 @@ module SiteSettingExtension
end
end
# Upcoming change settings have a supplemental array of group IDs that are used to opt-in
# certain groups to the change early. We use the data from SiteSettingGroup to define
# a getter with _groups_map on the end, e.g. allow_unlimited_uploads_groups_map,
# to avoid having to manually split and convert to integer for these settings.
if upcoming_change_metadata[name] && type_supervisor.get_type(name) == :bool
define_singleton_method("#{clean_name}_groups_map") do
site_setting_group_ids[name].presence || []
end
end
# Same logic as above for other list type settings, with the caveat that normal
# list settings are not necessarily integers, so we just want to handle the splitting.
if %i[list emoji_list tag_list].include?(type_supervisor.get_type(name))
@@ -959,6 +1035,14 @@ module SiteSettingExtension
end
)
if opts[:upcoming_change]
upcoming_change_metadata[name] = opts[:upcoming_change]
impact_type, impact_role = upcoming_change_metadata[name][:impact].split(",")
upcoming_change_metadata[name][:impact_type] = impact_type
upcoming_change_metadata[name][:impact_role] = impact_role
upcoming_change_metadata[name][:status] = opts[:upcoming_change][:status].to_sym
end
categories[name] = opts[:category] || :uncategorized
themeable[name] = opts[:themeable] ? true : false
+7
View File
@@ -57,4 +57,11 @@ class SiteSettings::DbProvider
@table_exists ||= {}
@table_exists[current_site] ||= ActiveRecord::Base.connection.table_exists?(@model.table_name)
end
def site_setting_groups_table_exists?
@site_setting_groups_table_exists ||= {}
@site_setting_groups_table_exists[current_site] ||= ActiveRecord::Base.connection.table_exists?(
"site_setting_groups",
)
end
end
+65
View File
@@ -0,0 +1,65 @@
# frozen_string_literal: true
module UpcomingChanges
def self.statuses
@statuses ||= Enum.new(pre_alpha: 0, alpha: 100, beta: 200, stable: 300, permanent: 500)
end
def self.image_exists?(change_setting_name)
File.exist?(File.join(Rails.public_path, self.image_path(change_setting_name)))
end
def self.image_path(change_setting_name)
File.join("images", "upcoming_changes", "#{change_setting_name}.png")
end
def self.image_data(change_setting_name)
width, height = nil, nil
File.open(File.join(Rails.public_path, image_path(change_setting_name)), "rb") do |file|
image_info = FastImage.new(file)
width, height = image_info.size
end
{ url: "#{Discourse.base_url}/#{image_path(change_setting_name)}", width:, height: }
end
def self.change_metadata(change_setting_name)
SiteSetting.upcoming_change_metadata[change_setting_name.to_sym] || {}
end
def self.not_yet_stable?(change_setting_name)
change_status_value(change_setting_name) < UpcomingChanges.statuses[:stable]
end
def self.stable_or_permanent?(change_setting_name)
change_status_value(change_setting_name) >= UpcomingChanges.statuses[:stable]
end
def self.meets_or_exceeds_status?(change_setting_name, status)
change_status_value(change_setting_name) >= UpcomingChanges.statuses[status]
end
def self.change_status_value(change_setting_name)
UpcomingChanges.statuses[change_status(change_setting_name)]
end
def self.change_status(change_setting_name)
change_metadata(change_setting_name)[:status]
end
def self.history_for(change_setting_name)
UserHistory.where(
action: UserHistory.actions[:upcoming_change_toggled],
subject: change_setting_name,
).order(created_at: :desc)
end
def self.has_groups?(change_setting_name)
group_ids_for(change_setting_name).present?
end
def self.group_ids_for(change_setting_name)
SiteSetting.site_setting_group_ids[change_setting_name].presence || []
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+112
View File
@@ -1007,6 +1007,118 @@ RSpec.describe SiteSettingExtension do
end
end
describe "site setting groups" do
before do
SiteSettingGroup.create!(
name: "enable_upload_debug_mode",
group_ids: "#{Group::AUTO_GROUPS[:trust_level_0]}|#{Group::AUTO_GROUPS[:trust_level_1]}",
)
end
it "returns the correct group for a setting" do
SiteSetting.refresh!
expect(SiteSetting.site_setting_group_ids[:enable_upload_debug_mode]).to eq([10, 11])
end
end
describe "#notify_clients!" do
context "when the site setting is an upcoming change" do
before do
mock_upcoming_change_metadata(
{
enable_upload_debug_mode: {
impact: "other,developers",
status: :pre_alpha,
impact_type: "other",
impact_role: "developers",
},
some_other_upcoming_setting: {
impact: "feature,staff",
status: :alpha,
impact_type: "feature",
impact_role: "staff",
},
},
)
SiteSetting.send(:setup_methods, :enable_upload_debug_mode)
SiteSetting.refresh!
end
after { SiteSetting.refresh! }
context "with site setting groups assigned" do
before do
SiteSettingGroup.create!(
name: "enable_upload_debug_mode",
group_ids:
"#{Group::AUTO_GROUPS[:trust_level_0]}|#{Group::AUTO_GROUPS[:trust_level_1]}",
)
SiteSetting.refresh!
end
after { SiteSetting.refresh! }
it "does not publish to MessageBus for the client settings channel" do
messages =
MessageBus.track_publish(SiteSettingExtension::CLIENT_SETTINGS_CHANNEL) do
SiteSetting.notify_clients!(:enable_upload_debug_mode)
end
expect(messages.length).to eq(0)
end
end
context "without site setting groups assigned" do
it "publishes to MessageBus with the setting name and value" do
messages =
MessageBus.track_publish(SiteSettingExtension::CLIENT_SETTINGS_CHANNEL) do
SiteSetting.notify_clients!(:enable_upload_debug_mode)
end
expect(messages.length).to eq(1)
expect(messages.first.data[:name]).to eq(:enable_upload_debug_mode)
expect(messages.first.data[:value]).to eq(SiteSetting.enable_upload_debug_mode)
end
end
end
context "when the site setting is not an upcoming change" do
it "publishes to MessageBus with the setting name and value" do
messages =
MessageBus.track_publish(SiteSettingExtension::CLIENT_SETTINGS_CHANNEL) do
SiteSetting.notify_clients!(:title)
end
expect(messages.length).to eq(1)
expect(messages.first.data[:name]).to eq(:title)
expect(messages.first.data[:value]).to eq(SiteSetting.title)
end
it "includes scoped_to parameter when provided" do
messages =
MessageBus.track_publish(SiteSettingExtension::CLIENT_SETTINGS_CHANNEL) do
SiteSetting.notify_clients!(:title, { theme_id: 123 })
end
expect(messages.length).to eq(1)
expect(messages.first.data[:scoped_to]).to eq({ theme_id: 123 })
end
end
context "with default_locale setting" do
it "uses the custom getter for default_locale" do
messages =
MessageBus.track_publish(SiteSettingExtension::CLIENT_SETTINGS_CHANNEL) do
SiteSetting.notify_clients!(:default_locale)
end
expect(messages.length).to eq(1)
expect(messages.first.data[:name]).to eq(:default_locale)
expect(messages.first.data[:value]).to eq(SiteSetting.default_locale)
end
end
end
describe "themeable settings" do
fab!(:theme_1, :theme)
fab!(:theme_2, :theme)
+283
View File
@@ -0,0 +1,283 @@
# frozen_string_literal: true
RSpec.describe UpcomingChanges do
let(:setting_name) { "enable_upload_debug_mode" }
before do
mock_upcoming_change_metadata(
{
enable_upload_debug_mode: {
impact: "other,developers",
status: :pre_alpha,
impact_type: "other",
impact_role: "developers",
},
alpha_setting: {
status: :alpha,
},
beta_setting: {
status: :beta,
},
stable_setting: {
status: :stable,
},
permanent_setting: {
status: :permanent,
},
},
)
# There is a fixture image at spec/fixtures/images/upcoming_changes/enable_upload_debug_mode.png,
# but normally upcoming change images are at Rails.public_path + /images/upcoming_changes/
Rails.stubs(:public_path).returns(File.join(Rails.root, "spec", "fixtures"))
end
describe ".image_exists?" do
it "returns true when the image file exists" do
expect(described_class.image_exists?(setting_name)).to eq(true)
end
it "returns false when the image file does not exist" do
expect(described_class.image_exists?("nonexistent_setting")).to eq(false)
end
end
describe ".image_path" do
it "returns the correct path for the image" do
expect(described_class.image_path(setting_name)).to eq(
"images/upcoming_changes/#{setting_name}.png",
)
end
end
describe ".image_data" do
it "returns image URL, width, and height" do
result = described_class.image_data(setting_name)
expect(result[:url]).to eq(
"#{Discourse.base_url}/images/upcoming_changes/#{setting_name}.png",
)
expect(result[:width]).to eq(244)
expect(result[:height]).to eq(66)
end
end
describe ".change_metadata" do
it "returns the metadata hash for a setting with metadata" do
metadata = described_class.change_metadata(setting_name)
expect(metadata).to eq(
{
impact: "other,developers",
status: :pre_alpha,
impact_type: "other",
impact_role: "developers",
},
)
end
it "returns an empty hash for a setting without metadata" do
metadata = described_class.change_metadata("nonexistent_setting")
expect(metadata).to eq({})
end
it "accepts string setting names" do
metadata = described_class.change_metadata(setting_name)
expect(metadata[:status]).to eq(:pre_alpha)
end
it "accepts symbol setting names" do
metadata = described_class.change_metadata(setting_name.to_sym)
expect(metadata[:status]).to eq(:pre_alpha)
end
end
describe ".not_yet_stable?" do
it "returns true for pre_alpha status" do
expect(described_class.not_yet_stable?(setting_name)).to eq(true)
end
it "returns true for alpha status" do
expect(described_class.not_yet_stable?("alpha_setting")).to eq(true)
end
it "returns true for beta status" do
expect(described_class.not_yet_stable?("beta_setting")).to eq(true)
end
it "returns false for stable status" do
expect(described_class.not_yet_stable?("stable_setting")).to eq(false)
end
it "returns false for permanent status" do
expect(described_class.not_yet_stable?("permanent_setting")).to eq(false)
end
end
describe ".stable_or_permanent?" do
it "returns false for pre_alpha status" do
expect(described_class.stable_or_permanent?(setting_name)).to eq(false)
end
it "returns false for alpha status" do
expect(described_class.stable_or_permanent?("alpha_setting")).to eq(false)
end
it "returns false for beta status" do
expect(described_class.stable_or_permanent?("beta_setting")).to eq(false)
end
it "returns true for stable status" do
expect(described_class.stable_or_permanent?("stable_setting")).to eq(true)
end
it "returns true for permanent status" do
expect(described_class.stable_or_permanent?("permanent_setting")).to eq(true)
end
end
describe ".change_status_value" do
it "returns 0 for pre_alpha status" do
expect(described_class.change_status_value(setting_name)).to eq(0)
end
it "returns 100 for alpha status" do
expect(described_class.change_status_value("alpha_setting")).to eq(100)
end
it "returns 200 for beta status" do
expect(described_class.change_status_value("beta_setting")).to eq(200)
end
it "returns 300 for stable status" do
expect(described_class.change_status_value("stable_setting")).to eq(300)
end
it "returns 500 for permanent status" do
expect(described_class.change_status_value("permanent_setting")).to eq(500)
end
end
describe ".change_status" do
it "returns :pre_alpha for pre_alpha status" do
expect(described_class.change_status(setting_name)).to eq(:pre_alpha)
end
it "returns :alpha for alpha status" do
expect(described_class.change_status("alpha_setting")).to eq(:alpha)
end
it "returns :beta for beta status" do
expect(described_class.change_status("beta_setting")).to eq(:beta)
end
it "returns :stable for stable status" do
expect(described_class.change_status("stable_setting")).to eq(:stable)
end
it "returns :permanent for permanent status" do
expect(described_class.change_status("permanent_setting")).to eq(:permanent)
end
end
describe ".meets_or_exceeds_status?" do
it "returns true when the change meets the required status" do
expect(described_class.meets_or_exceeds_status?("stable_setting", :beta)).to eq(true)
expect(described_class.meets_or_exceeds_status?("permanent_setting", :stable)).to eq(true)
end
it "returns false when the change does not meet the required status" do
expect(described_class.meets_or_exceeds_status?("alpha_setting", :beta)).to eq(false)
expect(described_class.meets_or_exceeds_status?("beta_setting", :stable)).to eq(false)
end
end
describe ".history_for" do
fab!(:admin)
it "returns UserHistory records for the given setting" do
UserHistory.create!(
action: UserHistory.actions[:upcoming_change_toggled],
subject: setting_name,
acting_user_id: admin.id,
)
history = described_class.history_for(setting_name)
expect(history.count).to eq(1)
expect(history.first.subject).to eq(setting_name)
expect(history.first.action).to eq(UserHistory.actions[:upcoming_change_toggled])
end
it "returns records ordered by created_at descending" do
first_history =
UserHistory.create!(
action: UserHistory.actions[:upcoming_change_toggled],
subject: setting_name,
acting_user_id: admin.id,
created_at: 2.days.ago,
)
second_history =
UserHistory.create!(
action: UserHistory.actions[:upcoming_change_toggled],
subject: setting_name,
acting_user_id: admin.id,
created_at: 1.day.ago,
)
history = described_class.history_for(setting_name)
expect(history.first.id).to eq(second_history.id)
expect(history.last.id).to eq(first_history.id)
end
it "returns only records matching the setting name" do
UserHistory.create!(
action: UserHistory.actions[:upcoming_change_toggled],
subject: setting_name,
acting_user_id: admin.id,
)
UserHistory.create!(
action: UserHistory.actions[:upcoming_change_toggled],
subject: "different_setting",
acting_user_id: admin.id,
)
history = described_class.history_for(setting_name)
expect(history.count).to eq(1)
expect(history.first.subject).to eq(setting_name)
end
it "returns only records with upcoming_change_toggled action" do
UserHistory.create!(
action: UserHistory.actions[:upcoming_change_toggled],
subject: setting_name,
acting_user_id: admin.id,
)
UserHistory.create!(
action: UserHistory.actions[:change_site_setting],
subject: setting_name,
acting_user_id: admin.id,
)
history = described_class.history_for(setting_name)
expect(history.count).to eq(1)
expect(history.first.action).to eq(UserHistory.actions[:upcoming_change_toggled])
end
it "returns an empty relation when no history exists" do
history = described_class.history_for("nonexistent_setting")
expect(history.count).to eq(0)
expect(history).to be_a(ActiveRecord::Relation)
end
end
end
+45
View File
@@ -191,6 +191,51 @@ RSpec.describe SiteSetting do
end
end
describe ".history_for" do
fab!(:admin)
it "returns an empty relation when no changes have been made" do
expect(SiteSetting.history_for(:title)).to be_empty
end
it "returns UserHistory records for the specified setting" do
StaffActionLogger.new(admin).log_site_setting_change(:title, "Old Title", "New Title")
StaffActionLogger.new(admin).log_site_setting_change(:title, "New Title", "Newer Title")
history = SiteSetting.history_for(:title)
expect(history.count).to eq(2)
expect(history.first.action).to eq(UserHistory.actions[:change_site_setting])
expect(history.first.subject).to eq("title")
expect(history.first.new_value).to eq("Newer Title")
expect(history.last.new_value).to eq("New Title")
end
it "returns only records for the specified setting" do
StaffActionLogger.new(admin).log_site_setting_change(:title, "Old", "New")
StaffActionLogger.new(admin).log_site_setting_change(
:contact_email,
"old@test.com",
"new@test.com",
)
history = SiteSetting.history_for(:title)
expect(history.count).to eq(1)
expect(history.first.subject).to eq("title")
end
it "returns records ordered by most recent first" do
StaffActionLogger.new(admin).log_site_setting_change(:title, "First", "Second")
StaffActionLogger.new(admin).log_site_setting_change(:title, "Second", "Third")
history = SiteSetting.history_for(:title)
expect(history.first.new_value).to eq("Third")
expect(history.last.new_value).to eq("Second")
end
end
describe "ImageQuality" do
describe "#png_to_jpg_quality" do
context "when set to zero" do
+1
View File
@@ -896,6 +896,7 @@ RSpec.configure do |config|
ActionMailer::Base.deliveries.clear
Discourse.redis.flushdb
Scheduler::Defer.do_all_work
clear_mocked_upcoming_change_metadata
end
config.after(:each, type: :system) do |example|
@@ -0,0 +1,254 @@
# frozen_string_literal: true
RSpec.describe Admin::Config::UpcomingChangesController do
fab!(:admin)
fab!(:user)
describe "#index" do
before do
mock_upcoming_change_metadata(
{
enable_upload_debug_mode: {
impact: "other,developers",
status: :pre_alpha,
impact_type: "other",
impact_role: "developers",
},
},
)
end
context "when logged in as non-admin" do
before { sign_in(user) }
it "returns 404" do
get "/admin/config/upcoming-changes.json", xhr: true
expect(response.status).to eq(404)
end
end
context "when logged in as admin" do
before { sign_in(admin) }
it "lists upcoming changes" do
get "/admin/config/upcoming-changes.json", xhr: true
expect(response.status).to eq(200)
expect(response.parsed_body["upcoming_changes"]).to be_an(Array)
end
it "includes the mocked upcoming change" do
get "/admin/config/upcoming-changes.json", xhr: true
mock_setting =
response.parsed_body["upcoming_changes"].find do |change|
change["setting"] == "enable_upload_debug_mode"
end
expect(mock_setting).to include(
"setting" => "enable_upload_debug_mode",
"humanized_name" => "Enable upload debug mode",
"value" => SiteSetting.enable_upload_debug_mode,
"upcoming_change" => {
"impact" => "other,developers",
"impact_role" => "developers",
"impact_type" => "other",
"status" => "pre_alpha",
},
)
end
it "includes group names when site setting groups are configured" do
SiteSettingGroup.create!(name: "enable_upload_debug_mode", group_ids: "10|11")
SiteSetting.refresh!
get "/admin/config/upcoming-changes.json", xhr: true
mock_setting =
response.parsed_body["upcoming_changes"].find do |change|
change["setting"] == "enable_upload_debug_mode"
end
expect(mock_setting["groups"]).to eq(%w[trust_level_0 trust_level_1])
end
end
end
describe "#update_groups" do
let(:setting_name) { "enable_upload_debug_mode" }
context "when logged in as non-admin" do
before { sign_in(user) }
it "returns 404" do
put "/admin/config/upcoming-changes/groups.json",
params: {
group_names: %w[trust_level_0 admins],
setting: setting_name,
}
expect(response.status).to eq(404)
end
end
context "when logged in as admin" do
before { sign_in(admin) }
it "returns 200 on success" do
put "/admin/config/upcoming-changes/groups.json",
params: {
group_names: %w[trust_level_0 admins],
setting: setting_name,
}
expect(response.status).to eq(200)
expect(response.parsed_body["success"]).to eq("OK")
end
it "creates a site setting group record" do
expect {
put "/admin/config/upcoming-changes/groups.json",
params: {
group_names: %w[trust_level_0 admins],
setting: setting_name,
}
}.to change { SiteSettingGroup.count }.by(1)
site_setting_group = SiteSettingGroup.find_by(name: setting_name)
expect(site_setting_group.group_ids.split("|").sort).to eq(%w[1 10])
end
it "updates an existing site setting group record" do
SiteSettingGroup.create!(name: setting_name, group_ids: "10|13")
expect {
put "/admin/config/upcoming-changes/groups.json",
params: {
group_names: %w[admins trust_level_3],
setting: setting_name,
}
}.not_to change { SiteSettingGroup.count }
expect(SiteSettingGroup.find_by(name: setting_name).group_ids).to eq("1|13")
end
it "deletes an existing site setting group record" do
SiteSettingGroup.create!(name: setting_name, group_ids: "10|13")
expect {
put "/admin/config/upcoming-changes/groups.json",
params: {
group_names: [],
setting: setting_name,
}
}.to change { SiteSettingGroup.count }.by(-1)
expect(response.status).to eq(200)
expect(SiteSettingGroup.exists?(name: setting_name)).to be_falsey
end
it "logs the change in staff action logs" do
expect {
put "/admin/config/upcoming-changes/groups.json",
params: {
group_names: %w[trust_level_0 admins],
setting: setting_name,
}
}.to change {
UserHistory.where(
action: UserHistory.actions[:change_site_setting_groups],
subject: setting_name,
).count
}.by(1)
end
it "returns 400 when setting is missing" do
put "/admin/config/upcoming-changes/groups.json",
params: {
group_names: %w[trust_level_0 admins],
}
expect(response.status).to eq(400)
expect(response.parsed_body["errors"]).to be_present
end
it "only includes existing groups when some don't exist" do
put "/admin/config/upcoming-changes/groups.json",
params: {
group_names: %w[trust_level_0 nonexistent_group admins],
setting: setting_name,
}
expect(response.status).to eq(200)
site_setting_group = SiteSettingGroup.find_by(name: setting_name)
expect(site_setting_group.group_ids.split("|").sort).to eq(%w[1 10])
end
end
end
describe "#toggle_change" do
let(:setting_name) { "experimental_form_templates" }
context "when logged in as non-admin" do
before { sign_in(user) }
it "returns 404" do
put "/admin/config/upcoming-changes/toggle.json", params: { setting_name: }
expect(response.status).to eq(404)
end
end
context "when logged in as admin" do
before { sign_in(admin) }
it "returns 200 on success" do
put "/admin/config/upcoming-changes/toggle.json", params: { setting_name: }
expect(response.status).to eq(200)
expect(response.parsed_body["success"]).to eq("OK")
end
it "toggles the setting from false to true" do
SiteSetting.experimental_form_templates = false
expect {
put "/admin/config/upcoming-changes/toggle.json", params: { setting_name: }
}.to change { SiteSetting.experimental_form_templates }.from(false).to(true)
end
it "toggles the setting from true to false" do
SiteSetting.experimental_form_templates = true
expect {
put "/admin/config/upcoming-changes/toggle.json", params: { setting_name: }
}.to change { SiteSetting.experimental_form_templates }.from(true).to(false)
end
it "logs the change in staff action logs" do
expect {
put "/admin/config/upcoming-changes/toggle.json", params: { setting_name: }
}.to change {
UserHistory.where(
action: UserHistory.actions[:change_site_setting],
subject: setting_name,
).count
}.by(1)
end
it "returns 400 when setting_name is missing" do
put "/admin/config/upcoming-changes/toggle.json"
expect(response.status).to eq(400)
expect(response.parsed_body["errors"]).to be_present
end
it "returns 403 when setting_name is invalid" do
put "/admin/config/upcoming-changes/toggle.json",
params: {
setting_name: "nonexistent_setting",
}
expect(response.status).to eq(403)
end
end
end
end
@@ -0,0 +1,72 @@
# frozen_string_literal: true
RSpec.describe ProblemCheck::UpcomingChangeStableOptedOut do
subject(:check) { described_class.new }
describe ".call" do
before do
mock_upcoming_change_metadata(
{
enable_upload_debug_mode: {
impact: "other,developers",
status: :stable,
impact_type: "feature",
impact_role: "admins",
},
},
)
end
context "when enable_upcoming_changes is disabled" do
before { SiteSetting.enable_upcoming_changes = false }
it { expect(check).to be_chill_about_it }
end
context "when enable_upcoming_changes is enabled" do
before { SiteSetting.enable_upcoming_changes = true }
context "when upcoming change is enabled (opted in)" do
before { SiteSetting.enable_upload_debug_mode = true }
it { expect(check).to be_chill_about_it }
end
context "when upcoming change is stable and not opted in" do
it { expect(check).to have_a_problem }
end
context "when upcoming change is not yet stable and not opted in" do
before do
mock_upcoming_change_metadata(
{
enable_upload_debug_mode: {
impact: "other,developers",
status: :alpha,
impact_type: "other",
impact_role: "developers",
},
},
)
end
it { expect(check).to be_chill_about_it }
end
context "when upcoming change is permanent and not opted in" do
before do
mock_upcoming_change_metadata(
enable_upload_debug_mode: {
impact: "other,developers",
status: :permanent,
impact_type: "other",
impact_role: "developers",
},
)
end
it { expect(check).to have_a_problem }
end
end
end
end
@@ -0,0 +1,148 @@
# frozen_string_literal: true
RSpec.describe SiteSetting::UpsertGroups do
describe described_class::Contract, type: :model do
it { is_expected.to validate_presence_of :setting }
end
describe ".call" do
subject(:result) { described_class.call(params:, **dependencies) }
fab!(:admin)
let(:params) { { group_names:, setting: } }
let(:dependencies) { { guardian: } }
let(:group_names) { %w[trust_level_0 admins] }
let(:setting) { "enable_upload_debug_mode" }
let(:guardian) { admin.guardian }
context "when setting is blank" do
let(:setting) { nil }
it { is_expected.to fail_a_contract }
end
context "when group names don't match any existing groups" do
let(:group_names) { ["nonexistent_group"] }
it { is_expected.to fail_to_find_a_model(:group_ids) }
end
context "when some group names exist and some don't" do
let(:group_names) { %w[trust_level_0 nonexistent_group admins] }
it { is_expected.to run_successfully }
it "only includes the existing groups" do
result
site_setting_group = SiteSettingGroup.find_by(name: setting)
expect(site_setting_group.group_ids).to eq("1|10")
end
end
context "when a non-admin user tries to upsert groups" do
let(:guardian) { Guardian.new }
it { is_expected.to fail_a_policy(:current_user_is_admin) }
end
context "when an admin user upserts groups for a setting" do
it { is_expected.to run_successfully }
it "creates a new site setting group record" do
expect { result }.to change { SiteSettingGroup.count }.by(1)
end
it "stores the group ids in pipe-delimited format" do
result
site_setting_group = SiteSettingGroup.find_by(name: setting)
expect(site_setting_group.group_ids).to eq("1|10")
end
it "creates an entry in the staff action logs" do
expect { result }.to change {
UserHistory.where(
action: UserHistory.actions[:change_site_setting_groups],
subject: setting,
).count
}.by(1)
history = UserHistory.where(subject: setting).last
expect(history.previous_value).to be_nil
expect(history.new_value).to eq("1|10")
end
it "notifies that site settings have changed" do
SiteSetting.expects(:notify_changed!).once
result
end
it "refreshes the site setting group ids for this process" do
SiteSetting.expects(:refresh_site_setting_group_ids!).once
result
end
end
context "when an admin user updates groups for an existing setting" do
before { SiteSettingGroup.create!(name: setting, group_ids: "10|13") }
let(:group_names) { %w[admins trust_level_3] }
it { is_expected.to run_successfully }
it "does not create a new record" do
expect { result }.not_to change { SiteSettingGroup.count }
end
it "updates the existing site setting group record" do
expect { result }.to change { SiteSettingGroup.find_by(name: setting).group_ids }.from(
"10|13",
).to("1|13")
end
it "creates an entry in the staff action logs with previous value" do
expect { result }.to change {
UserHistory.where(
action: UserHistory.actions[:change_site_setting_groups],
subject: setting,
).count
}.by(1)
history = UserHistory.where(subject: setting).last
expect(history.previous_value).to eq("10|13")
expect(history.new_value).to eq("1|13")
end
it "notifies that site settings have changed" do
SiteSetting.expects(:notify_changed!).once
result
end
context "when group_names are empty" do
let(:group_names) { [] }
it "deletes the existing site setting group record" do
expect { result }.to change { SiteSettingGroup.where(name: setting).count }.by(-1)
end
it "refreshes the site setting group ids for this process" do
SiteSetting.expects(:refresh_site_setting_group_ids!).once
result
end
it "creates an entry in the staff action logs with previous value and empty new value" do
expect { result }.to change {
UserHistory.where(
action: UserHistory.actions[:change_site_setting_groups],
subject: setting,
).count
}.by(1)
history = UserHistory.where(subject: setting).last
expect(history.previous_value).to eq("10|13")
expect(history.new_value).to eq("")
end
end
end
end
end
+27
View File
@@ -208,6 +208,33 @@ RSpec.describe StaffActionLogger do
end
end
# allow_user_locale is just used as an example here because upcoming
# changes will not exist forever, so there aren't any stable names we
# can use
describe "log_upcoming_change_toggle" do
it "raises an error when params are invalid" do
expect { logger.log_upcoming_change_toggle(nil, false, true) }.to raise_error(
Discourse::InvalidParameters,
)
expect {
logger.log_upcoming_change_toggle("change_that_will_not_exist", false, true)
}.to raise_error(Discourse::InvalidParameters)
end
it "creates a new UserHistory record" do
expect { logger.log_upcoming_change_toggle("allow_user_locale", false, true) }.to change {
UserHistory.count
}.by(1)
end
it "records the details of why the toggle happened" do
details =
"This upcoming change was automatically enabled because it reached the 'Beta' status, which meets or exceeds the defined promotion status of 'Beta' on your site. See <a href='#{Discourse.base_url}/admin/config/upcoming-changes'>the upcoming changes page for details</a>."
result = logger.log_upcoming_change_toggle("allow_user_locale", false, true, { details: })
expect(result.details).to eq(details)
end
end
describe "log_theme_change" do
fab!(:theme)
@@ -0,0 +1,83 @@
# frozen_string_literal: true
RSpec.describe UpcomingChanges::List do
describe ".call" do
subject(:result) { described_class.call(params:, **dependencies) }
fab!(:admin)
let(:dependencies) { { guardian: } }
let(:guardian) { admin.guardian }
let(:params) { {} }
before do
mock_upcoming_change_metadata(
{
enable_upload_debug_mode: {
impact: "other,developers",
status: :pre_alpha,
impact_type: "other",
impact_role: "developers",
},
},
)
end
context "when a non-admin user tries to list upcoming changes" do
let(:guardian) { Guardian.new }
it { is_expected.to fail_a_policy(:current_user_is_admin) }
end
context "when everything's ok" do
it { is_expected.to run_successfully }
it "has all the necessary data for the change" do
results = result.upcoming_changes
mock_setting = results.find { |change| change[:setting] == :enable_upload_debug_mode }
expect(mock_setting).to include(
setting: :enable_upload_debug_mode,
humanized_name: "Enable upload debug mode",
description: "",
value: SiteSetting.enable_upload_debug_mode,
upcoming_change: {
impact: "other,developers",
impact_role: "developers",
impact_type: "other",
status: :pre_alpha,
},
)
end
it "includes the image_url if there is an image for the change in public/images" do
Rails.stubs(:public_path).returns(File.join(Rails.root, "spec", "fixtures"))
results = result.upcoming_changes
mock_setting = results.find { |change| change[:setting] == :enable_upload_debug_mode }
expect(mock_setting[:upcoming_change][:image]).to eq(
{
url: "#{Discourse.base_url}/#{UpcomingChanges.image_path(mock_setting[:setting])}",
width: 244,
height: 66,
},
)
end
it "includes the plugin name if the setting is from a plugin" do
results = result.upcoming_changes
sample_plugin_setting =
results.find { |change| change[:setting] == :enable_experimental_sample_plugin_feature }
expect(sample_plugin_setting[:plugin]).to eq("Sample plugin")
end
it "includes the group names if there are site setting group IDs for the change" do
SiteSettingGroup.create!(name: "enable_upload_debug_mode", group_ids: "10|11")
SiteSetting.refresh!
results = result.upcoming_changes
mock_setting = results.find { |change| change[:setting] == :enable_upload_debug_mode }
expect(mock_setting[:groups]).to eq(%w[trust_level_0 trust_level_1])
end
end
end
end
@@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe Experiments::Toggle do
RSpec.describe UpcomingChanges::Toggle do
describe described_class::Contract, type: :model do
it { is_expected.to validate_presence_of :setting_name }
end
@@ -59,6 +59,23 @@ RSpec.describe Experiments::Toggle do
).count
}.by(1)
end
context "when enable_upcoming_changes is enabled" do
before { SiteSetting.enable_upcoming_changes = true }
it "creates an entry in the staff action logs with correct context" do
expect { result }.to change {
UserHistory.where(
action: UserHistory.actions[:upcoming_change_toggled],
subject: "experimental_form_templates",
).count
}.by(1)
expect(UserHistory.last.context).to eq(
I18n.t("staff_action_logs.upcoming_changes.log_manually_toggled"),
)
end
end
end
end
end
+19
View File
@@ -329,6 +329,25 @@ module Helpers
retry
end
def mock_upcoming_change_metadata(metadata)
@original_upcoming_changes_metadata = SiteSetting.upcoming_change_metadata.dup
# We do this because upcoming changes are ephemeral in site settings,
# so we cannot rely on them for specs. Instead we can fake some metadata
# for an existing stable setting.
SiteSetting.instance_variable_set(
:@upcoming_change_metadata,
@original_upcoming_changes_metadata.merge(metadata),
)
end
def clear_mocked_upcoming_change_metadata
SiteSetting.instance_variable_set(
:@upcoming_change_metadata,
@original_upcoming_changes_metadata,
)
end
private
def directory_from_caller
@@ -7,3 +7,10 @@ site_settings:
default: 0
test_some_other_topic_id:
default: 0
enable_experimental_sample_plugin_feature:
default: false
client: true
hidden: true
upcoming_change:
impact: "feature,admins"
status: "alpha"
@@ -130,7 +130,7 @@ describe "Admin Color Palettes Config Area Page", type: :system do
find(".admin-filter-controls__input").fill_in(with: "bananas")
expect(page).to have_css(".admin-filter-controls__no-results")
expect(page).to have_css("button", text: I18n.t("admin_js.admin.plugins.filters.reset"))
expect(page).to have_css("button", text: I18n.t("admin_js.reset_filter"))
end
end
+11 -9
View File
@@ -98,16 +98,18 @@ describe "Admin | Sidebar Navigation", type: :system do
)
sidebar.toggle_all_sections
expect(page).to have_selector(".sidebar-section-link-content-text", count: 5)
expect(all(".sidebar-section-link-content-text").map(&:text)).to eq(
[
I18n.t("admin_js.admin.dashboard.title"),
I18n.t("admin_js.admin.config.users.title"),
I18n.t("admin_js.admin.config.groups.title"),
I18n.t("admin_js.admin.config.site_settings.title"),
I18n.t("admin_js.admin.config.whats_new.title"),
],
expected_links = [
I18n.t("admin_js.admin.dashboard.title"),
I18n.t("admin_js.admin.config.users.title"),
I18n.t("admin_js.admin.config.groups.title"),
I18n.t("admin_js.admin.config.site_settings.title"),
I18n.t("admin_js.admin.config.whats_new.title"),
]
expect(page).to have_selector(
".sidebar-section-link-content-text",
count: expected_links.length,
)
expect(all(".sidebar-section-link-content-text").map(&:text)).to eq(expected_links)
sidebar.toggle_all_sections
expect(page).to have_selector(
+151
View File
@@ -0,0 +1,151 @@
# frozen_string_literal: true
describe "Admin upcoming changes", type: :system do
fab!(:current_user, :admin)
let(:upcoming_changes_page) { PageObjects::Pages::AdminUpcomingChanges.new }
before do
SiteSetting.enable_upcoming_changes = true
mock_upcoming_change_metadata(
{
enable_upload_debug_mode: {
impact: "other,developers",
status: :pre_alpha,
impact_type: "other",
impact_role: "developers",
},
about_page_extra_groups_show_description: {
impact: "feature,all_members",
status: :stable,
impact_type: "feature",
impact_role: "all_members",
},
},
)
sign_in(current_user)
end
it "shows a list of upcoming changes and their metadata" do
upcoming_changes_page.visit
expect(upcoming_changes_page).to have_change(:about_page_extra_groups_show_description)
expect(upcoming_changes_page).to have_change(:enable_upload_debug_mode)
expect(
upcoming_changes_page.change_item(:about_page_extra_groups_show_description),
).to have_status(:stable)
expect(
upcoming_changes_page.change_item(:about_page_extra_groups_show_description),
).to have_impact_role(:all_members)
end
it "shows upcoming changes from plugins" do
upcoming_changes_page.visit
expect(upcoming_changes_page).to have_change(:enable_experimental_sample_plugin_feature)
expect(
upcoming_changes_page.change_item(:enable_experimental_sample_plugin_feature),
).to have_plugin_name("Sample plugin")
end
it "can toggle an upcoming change on or off" do
upcoming_changes_page.visit
expect(upcoming_changes_page.change_item(:enable_upload_debug_mode)).to be_disabled
upcoming_changes_page.change_item(:enable_upload_debug_mode).toggle
expect(page).to have_content(I18n.t("admin_js.admin.upcoming_changes.change_enabled"))
expect(upcoming_changes_page.change_item(:enable_upload_debug_mode)).to be_enabled
# Revisit the page to skip the 3s toggle rate limit
upcoming_changes_page.visit
expect(upcoming_changes_page.change_item(:enable_upload_debug_mode)).to be_enabled
upcoming_changes_page.change_item(:enable_upload_debug_mode).toggle
expect(page).to have_content(I18n.t("admin_js.admin.upcoming_changes.change_disabled"))
end
it "can add and remove groups for a change" do
SiteSettingGroup.create!(
name: "enable_upload_debug_mode",
group_ids: Group::AUTO_GROUPS[:trust_level_4].to_s,
)
SiteSetting.refresh_site_setting_group_ids!
SiteSetting.notify_changed!
upcoming_changes_page.visit
expect(upcoming_changes_page.change_item(:enable_upload_debug_mode)).to have_groups(
"trust_level_4",
)
upcoming_changes_page.change_item(:enable_upload_debug_mode).add_group("staff")
expect(page).to have_content(I18n.t("admin_js.admin.upcoming_changes.groups_updated"))
expect(
SiteSettingGroup.find_by(name: "enable_upload_debug_mode").group_ids.split("|").map(&:to_i),
).to match_array([Group::AUTO_GROUPS[:trust_level_4], Group::AUTO_GROUPS[:staff]])
expect(SiteSetting.site_setting_group_ids[:enable_upload_debug_mode]).to match_array(
[Group::AUTO_GROUPS[:trust_level_4], Group::AUTO_GROUPS[:staff]],
)
upcoming_changes_page.visit
expect(upcoming_changes_page.change_item(:enable_upload_debug_mode)).to have_groups(
"trust_level_4",
"staff",
)
upcoming_changes_page.change_item(:enable_upload_debug_mode).remove_group("trust_level_4")
expect(page).to have_content(I18n.t("admin_js.admin.upcoming_changes.groups_updated"))
expect(
SiteSettingGroup.find_by(name: "enable_upload_debug_mode").group_ids.split("|").map(&:to_i),
).to match_array([Group::AUTO_GROUPS[:staff]])
expect(SiteSetting.site_setting_group_ids[:enable_upload_debug_mode]).to match_array(
[Group::AUTO_GROUPS[:staff]],
)
end
it "can filter by name, description, plugin, status, impact type, or enabled/disabled" do
upcoming_changes_page.visit
# Filter by name
upcoming_changes_page.filter_controls.type_in_search("upload debug")
expect(upcoming_changes_page).to have_change(:enable_upload_debug_mode)
expect(upcoming_changes_page).to have_no_change(:about_page_extra_groups_show_description)
upcoming_changes_page.filter_controls.clear_search
# Filter by plugin
upcoming_changes_page.filter_controls.type_in_search("sample plugin")
expect(upcoming_changes_page).to have_change(:enable_experimental_sample_plugin_feature)
expect(upcoming_changes_page).to have_no_change(:about_page_extra_groups_show_description)
upcoming_changes_page.filter_controls.clear_search
upcoming_changes_page.filter_controls.toggle_dropdown_filters
# Filter by status
upcoming_changes_page.filter_controls.select_dropdown_option("Stable", dropdown_id: "status")
expect(upcoming_changes_page).to have_no_change(:enable_upload_debug_mode)
expect(upcoming_changes_page).to have_change(:about_page_extra_groups_show_description)
upcoming_changes_page.filter_controls.select_all_dropdown_option(dropdown_id: "status")
# Filter by impact type
upcoming_changes_page.filter_controls.select_dropdown_option("Feature", dropdown_id: "type")
expect(upcoming_changes_page).to have_no_change(:enable_upload_debug_mode)
expect(upcoming_changes_page).to have_change(:about_page_extra_groups_show_description)
upcoming_changes_page.filter_controls.select_all_dropdown_option(dropdown_id: "type")
# Filter by enabled/disabled
upcoming_changes_page.filter_controls.select_dropdown_option("Enabled", dropdown_id: "enabled")
expect(upcoming_changes_page).to have_no_change(:enable_upload_debug_mode)
expect(upcoming_changes_page).to have_no_change(:about_page_extra_groups_show_description)
upcoming_changes_page.filter_controls.select_all_dropdown_option(dropdown_id: "enabled")
end
end
@@ -3,8 +3,9 @@
module PageObjects
module Components
class AdminFilterControls < PageObjects::Components::Base
def initialize(component_selector)
def initialize(component_selector, has_multiple_dropdowns: false)
@component_selector = component_selector
@has_multiple_dropdowns = has_multiple_dropdowns
end
def component
@@ -19,8 +20,20 @@ module PageObjects
component.find(".admin-filter-controls__input").set("")
end
def select_dropdown_option(text)
component.find(".admin-filter-controls__dropdown").select(text)
def select_dropdown_option(text, dropdown_id: nil)
selector = ".admin-filter-controls__dropdown"
selector += "#{selector}--#{dropdown_id}" if dropdown_id
component.find(selector).select(text)
end
def select_all_dropdown_option(dropdown_id: nil)
selector = ".admin-filter-controls__dropdown"
selector += "#{selector}--#{dropdown_id}" if dropdown_id
find(selector).find("option[value='all']").select_option
end
def toggle_dropdown_filters
component.find(".admin-filter-controls__toggle-filters").click
end
def has_reset_button?
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module PageObjects
module Components
# NOTE: GroupSelector is the only user of the DMultiSelect component at the moment.
# At some point, we might want to make a separate PageObject for DMultiSelect if
# more components start using it.
class GroupSelector < PageObjects::Components::Base
def initialize(context)
@context = context
end
def has_selected_groups?(*group_names)
selected_groups =
find("#{@context} .group-selector")
.all(".d-multi-select-trigger__selected-item")
.map { |item| item[:innerText] }
expect(selected_groups & group_names).to match_array(group_names)
end
def open
find(@context).find(".group-selector .d-multi-select-trigger__expand-btn").click
expect(page).to have_css(".fk-d-menu.d-multi-select-content")
end
def add_group(group_name)
self.open
find(".dropdown-menu__item.d-multi-select__search-container").fill_in(with: group_name)
find(".dropdown-menu__item.d-multi-select__result[title='#{group_name}']").click
find(@context).find(".upcoming-change__save-groups").click
end
def remove_group(group_name)
find(@context)
.find(".group-selector .d-multi-select-trigger__selected-item", text: group_name)
.find(".d-multi-select-trigger__remove-selection-icon")
.click
find(@context).find(".upcoming-change__save-groups").click
end
end
end
end
@@ -0,0 +1,79 @@
# frozen_string_literal: true
module PageObjects
module Components
class AdminUpcomingChangeItem < PageObjects::Components::Base
def initialize(setting_name)
@setting_name = setting_name
end
def has_text?(text)
find_item(@setting_name).has_text?(text)
end
def find_item(setting_name)
page.find(change_item_selector(setting_name))
end
def exists?
page.has_css?(change_item_selector(@setting_name))
end
def does_not_exist?
page.has_no_css?(change_item_selector(@setting_name))
end
def change_item_selector(setting_name)
".upcoming-change-row[data-upcoming-change='#{setting_name}']"
end
def has_status?(status)
find_item(@setting_name).has_css?(".upcoming-change__badge.--status-#{status}")
end
def has_impact_role?(impact_type)
find_item(@setting_name).has_css?(".upcoming-change__badge.--impact-role-#{impact_type}")
end
def has_plugin_name?(plugin_name)
find_item(@setting_name).find(".upcoming-change__plugin").has_text?(plugin_name)
end
def toggle
PageObjects::Components::DToggleSwitch.new(
"#{change_item_selector(@setting_name)} .upcoming-change__toggle",
).toggle
end
def enabled?
PageObjects::Components::DToggleSwitch.new(
"#{change_item_selector(@setting_name)} .upcoming-change__toggle",
).checked?
end
def disabled?
PageObjects::Components::DToggleSwitch.new(
"#{change_item_selector(@setting_name)} .upcoming-change__toggle",
).unchecked?
end
def has_groups?(*group_names)
PageObjects::Components::GroupSelector.new(
change_item_selector(@setting_name),
).has_selected_groups?(*group_names)
end
def add_group(group_name)
PageObjects::Components::GroupSelector.new(change_item_selector(@setting_name)).add_group(
group_name,
)
end
def remove_group(group_name)
PageObjects::Components::GroupSelector.new(
change_item_selector(@setting_name),
).remove_group(group_name)
end
end
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminUpcomingChanges < PageObjects::Pages::Base
def visit
page.visit("/admin/config/upcoming-changes")
end
def change_item(setting_name)
PageObjects::Components::AdminUpcomingChangeItem.new(setting_name)
end
def has_change?(setting_name, description: nil)
description ||= SiteSettings::LabelFormatter.description(setting_name)
change_item(setting_name).exists?
change_item(setting_name).has_text?(description)
change_item(setting_name).has_text?(
SiteSettings::LabelFormatter.humanized_name(setting_name),
)
end
def has_no_change?(setting_name)
change_item(setting_name).does_not_exist?
end
def filter_controls
PageObjects::Components::AdminFilterControls.new(
".upcoming-changes",
has_multiple_dropdowns: true,
)
end
end
end
end