mirror of
https://github.com/discourse/discourse.git
synced 2026-08-02 09:29:37 -05:00
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:
co-authored by
awesomerobot
parent
93862b98dd
commit
d4ac43e605
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
#
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 community’s 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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+18
-1
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user