FEATURE: Allow site settings to be edited throughout admin UI (#26154)

This commit makes it so the site settings filter controls and
the list of settings input editors themselves can be used elsewhere
in the admin UI outside of /admin/site_settings

This allows us to provide more targeted groups of settings in different
UI areas where it makes sense to provide them, such as on plugin pages.
You could open a single page for a plugin where you can see information
about that plugin, change settings, and configure it with custom UIs
in the one place.

In future we will do this in "config areas" for other parts of the
admin UI.
This commit is contained in:
Martin Brennan 2024-03-18 08:50:39 +10:00 committed by GitHub
parent d0d659e733
commit 78bafb331a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 532 additions and 262 deletions

View File

@ -0,0 +1,75 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { cancel } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import SiteSettingFilter from "discourse/lib/site-setting-filter";
import i18n from "discourse-common/helpers/i18n";
import discourseDebounce from "discourse-common/lib/debounce";
import AdminSiteSettingsFilterControls from "admin/components/admin-site-settings-filter-controls";
import SiteSetting from "admin/components/site-setting";
export default class AdminPluginFilteredSiteSettings extends Component {
@service currentUser;
@tracked visibleSettings;
@tracked loading = true;
siteSettingFilter = new SiteSettingFilter(this.args.settings);
constructor() {
super(...arguments);
this.filterChanged({ filter: "", onlyOverridden: false });
}
filterSettings(filterData) {
this.args.onFilterChanged(filterData);
this.visibleSettings = this.siteSettingFilter.filterSettings(
filterData.filter,
{
includeAllCategory: false,
onlyOverridden: filterData.onlyOverridden,
}
)[0]?.siteSettings;
this.loading = false;
}
@action
filterChanged(filterData) {
this._debouncedOnChangeFilter(filterData);
}
get noResults() {
return isEmpty(this.visibleSettings) && !this.loading;
}
_debouncedOnChangeFilter(filterData) {
cancel(this.onChangeFilterHandler);
this.onChangeFilterHandler = discourseDebounce(
this,
this.filterSettings,
filterData,
100
);
}
<template>
<AdminSiteSettingsFilterControls
@onChangeFilter={{this.filterChanged}}
@initialFilter={{@initialFilter}}
/>
<ConditionalLoadingSpinner @condition={{this.loading}}>
<section class="form-horizontal settings">
{{#each this.visibleSettings as |setting|}}
<SiteSetting @setting={{setting}} />
{{/each}}
{{#if this.noResults}}
{{i18n "admin.site_settings.no_results"}}
{{/if}}
</section>
</ConditionalLoadingSpinner>
</template>
}

View File

@ -0,0 +1,87 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import DButton from "discourse/components/d-button";
import TextField from "discourse/components/text-field";
import i18n from "discourse-common/helpers/i18n";
export default class AdminSiteSettingsFilterControls extends Component {
@tracked filter = this.args.initialFilter || "";
@tracked onlyOverridden = false;
@action
clearFilter() {
this.filter = "";
this.onlyOverridden = false;
this.onChangeFilter();
}
@action
onChangeFilter() {
this.args.onChangeFilter({
filter: this.filter,
onlyOverridden: this.onlyOverridden,
});
}
@action
onToggleOverridden(event) {
this.onlyOverridden = event.target.checked;
this.onChangeFilter();
}
@action
runInitialFilter() {
this.onChangeFilter();
}
<template>
<div
class="admin-controls admin-site-settings-filter-controls"
{{didInsert this.runInitialFilter}}
>
<div class="controls">
<div class="inline-form">
{{#if @showMenu}}
<DButton
@action={{@onToggleMenu}}
@icon="bars"
class="menu-toggle"
/>
{{/if}}
<TextField
@type="text"
@value={{this.filter}}
placeholder={{i18n "type_to_filter"}}
@onChange={{this.onChangeFilter}}
class="no-blur"
id="setting-filter"
autocomplete="off"
/>
<DButton
@action={{this.clearFilter}}
@label="admin.site_settings.clear_filter"
id="clear-filter"
class="btn-default"
/>
</div>
</div>
<div class="search controls">
<label>
<Input
@type="checkbox"
@checked={{this.onlyOverridden}}
class="toggle-overridden"
id="setting-filter-toggle-overridden"
{{on "click" this.onToggleOverridden}}
/>
{{i18n "admin.settings.show_overriden"}}
</label>
</div>
</div>
</template>
}

View File

@ -1,7 +1,12 @@
{{#if this.setting.textarea}}
<Textarea @value={{this.value}} class="input-setting-textarea" />
{{else if this.isSecret}}
<Input @type="password" @value={{this.value}} class="input-setting-string" />
<Input
@type="password"
@value={{this.value}}
class="input-setting-string"
autocomplete="new-password"
/>
{{else}}
<TextField @value={{this.value}} @classNames="input-setting-string" />
{{/if}}

View File

@ -0,0 +1,11 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
export default class AdminSiteSettingsController extends Controller {
filter = "";
@action
filterChanged(filterData) {
this.set("filter", filterData.filter);
}
}

View File

@ -3,151 +3,27 @@ import { action } from "@ember/object";
import { alias } from "@ember/object/computed";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { observes } from "@ember-decorators/object";
import SiteSettingFilter from "discourse/lib/site-setting-filter";
import { INPUT_DELAY } from "discourse-common/config/environment";
import { debounce } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
export default class AdminSiteSettingsController extends Controller {
@service router;
filter = "";
@alias("model") allSiteSettings;
filter = "";
visibleSiteSettings = null;
onlyOverridden = false;
siteSettingFilter = null;
get maxResults() {
return 100;
}
filterContentNow(filterData, category) {
this.siteSettingFilter ??= new SiteSettingFilter(this.allSiteSettings);
sortSettings(settings) {
// Sort the site settings so that fuzzy results are at the bottom
// and ordered by their gap count asc.
return settings.sort((a, b) => {
const aWeight = a.weight === undefined ? 0 : a.weight;
const bWeight = b.weight === undefined ? 0 : b.weight;
return aWeight - bWeight;
});
}
performSearch(filter, allSiteSettings, onlyOverridden) {
let pluginFilter;
if (filter) {
filter = filter
.toLowerCase()
.split(" ")
.filter((word) => {
if (word.length === 0) {
return false;
}
if (word.startsWith("plugin:")) {
pluginFilter = word.slice("plugin:".length).trim();
return false;
}
return true;
})
.join(" ")
.trim();
}
const all = {
nameKey: "all_results",
name: I18n.t("admin.site_settings.categories.all_results"),
siteSettings: [],
};
const matchesGroupedByCategory = [all];
const matches = [];
const strippedQuery = filter.replace(/[^a-z0-9]/gi, "");
let fuzzyRegex;
let fuzzyRegexGaps;
if (strippedQuery.length > 2) {
fuzzyRegex = new RegExp(strippedQuery.split("").join(".*"), "i");
fuzzyRegexGaps = new RegExp(strippedQuery.split("").join("(.*)"), "i");
}
allSiteSettings.forEach((settingsCategory) => {
let fuzzyMatches = [];
const siteSettings = settingsCategory.siteSettings.filter((item) => {
if (onlyOverridden && !item.get("overridden")) {
return false;
}
if (pluginFilter && item.plugin !== pluginFilter) {
return false;
}
if (filter) {
const setting = item.get("setting").toLowerCase();
let filterResult =
setting.includes(filter) ||
setting.replace(/_/g, " ").includes(filter) ||
item.get("description").toLowerCase().includes(filter) ||
(item.get("keywords") || "")
.replace(/_/g, " ")
.toLowerCase()
.includes(filter.replace(/_/g, " ")) ||
(item.get("value") || "").toString().toLowerCase().includes(filter);
if (!filterResult && fuzzyRegex && fuzzyRegex.test(setting)) {
// Tightens up fuzzy search results a bit.
const fuzzySearchLimiter = 25;
const strippedSetting = setting.replace(/[^a-z0-9]/gi, "");
if (
strippedSetting.length <=
strippedQuery.length + fuzzySearchLimiter
) {
const gapResult = strippedSetting.match(fuzzyRegexGaps);
if (gapResult) {
item.weight = gapResult.filter((gap) => gap !== "").length;
}
fuzzyMatches.push(item);
}
}
return filterResult;
} else {
return true;
}
});
if (fuzzyMatches.length > 0) {
siteSettings.pushObjects(fuzzyMatches);
}
if (siteSettings.length > 0) {
matches.pushObjects(siteSettings);
matchesGroupedByCategory.pushObject({
nameKey: settingsCategory.nameKey,
name: I18n.t(
"admin.site_settings.categories." + settingsCategory.nameKey
),
siteSettings: this.sortSettings(siteSettings),
count: siteSettings.length,
});
}
});
all.siteSettings.pushObjects(matches.slice(0, this.maxResults));
all.siteSettings = this.sortSettings(all.siteSettings);
all.hasMore = matches.length > this.maxResults;
all.count = all.hasMore ? `${this.maxResults}+` : matches.length;
all.maxResults = this.maxResults;
return matchesGroupedByCategory;
}
filterContentNow(category) {
if (isEmpty(this.allSiteSettings)) {
return;
}
if (isEmpty(this.filter) && !this.onlyOverridden) {
if (isEmpty(filterData.filter) && !filterData.onlyOverridden) {
this.set("visibleSiteSettings", this.allSiteSettings);
if (this.categoryNameKey === "all_results") {
this.router.transitionTo("adminSiteSettings");
@ -155,10 +31,11 @@ export default class AdminSiteSettingsController extends Controller {
return;
}
const matchesGroupedByCategory = this.performSearch(
this.filter,
this.allSiteSettings,
this.onlyOverridden
this.set("filter", filterData.filter);
const matchesGroupedByCategory = this.siteSettingFilter.filterSettings(
filterData.filter,
{ onlyOverridden: filterData.onlyOverridden }
);
const categoryMatches = matchesGroupedByCategory.findBy(
@ -177,25 +54,20 @@ export default class AdminSiteSettingsController extends Controller {
);
}
@observes("filter", "onlyOverridden", "model")
optsChanged() {
this.filterContent();
}
@debounce(INPUT_DELAY)
filterContent() {
filterContent(filterData) {
if (this._skipBounce) {
this.set("_skipBounce", false);
} else {
if (!this.isDestroyed) {
this.filterContentNow(this.categoryNameKey);
this.filterContentNow(filterData, this.categoryNameKey);
}
}
}
@action
clearFilter() {
this.setProperties({ filter: "", onlyOverridden: false });
filterChanged(filterData) {
this.filterContent(filterData);
}
@action

View File

@ -5,8 +5,10 @@ import I18n from "discourse-i18n";
import Setting from "admin/mixins/setting-object";
export default class SiteSetting extends EmberObject.extend(Setting) {
static findAll() {
return ajax("/admin/site_settings").then(function (settings) {
static findAll(params = {}) {
return ajax("/admin/site_settings", { data: params }).then(function (
settings
) {
// Group the results by category
const categories = {};
settings.site_settings.forEach(function (s) {

View File

@ -0,0 +1,18 @@
import Route from "@ember/routing/route";
import { inject as service } from "@ember/service";
import SiteSetting from "admin/models/site-setting";
export default class AdminPluginsShowSettingsRoute extends Route {
@service router;
queryParams = {
filter: { replace: true },
};
model(params) {
const plugin = this.modelFor("adminPlugins.show");
return SiteSetting.findAll({ plugin: plugin.name }).then((settings) => {
return { plugin, settings, initialFilter: params.filter };
});
}
}

View File

@ -1 +1,10 @@
<div class="content-body admin-plugin-config-area__settings"></div>
<div
class="content-body admin-plugin-config-area__settings admin-detail pull-left"
>
<AdminPluginFilteredSiteSettings
@initialFilter={{@model.initialFilter}}
@plugin={{@model.plugin}}
@settings={{@model.settings}}
@onFilterChanged={{this.filterChanged}}
/>
</div>

View File

@ -1,33 +1,9 @@
<div class="admin-controls">
<div class="controls">
<div class="inline-form">
<DButton @action={{this.toggleMenu}} @icon="bars" class="menu-toggle" />
<TextField
@id="setting-filter"
@value={{this.filter}}
@placeholderKey="type_to_filter"
class="no-blur"
<AdminSiteSettingsFilterControls
@initialFilter={{this.filter}}
@onChangeFilter={{this.filterChanged}}
@showMenu={{true}}
@onToggleMenu={{this.toggleMenu}}
/>
<DButton
@action={{this.clearFilter}}
@label="admin.site_settings.clear_filter"
id="clear-filter"
class="btn-default"
/>
</div>
</div>
<div class="search controls">
<label>
<Input
@type="checkbox"
@checked={{this.onlyOverridden}}
class="toggle-overridden"
/>
{{i18n "admin.settings.show_overriden"}}
</label>
</div>
</div>
<div class="admin-nav admin-site-settings-category-nav pull-left">
<ul class="nav nav-stacked">

View File

@ -0,0 +1,144 @@
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
export default class SiteSettingFilter {
constructor(siteSettings) {
this.siteSettings = siteSettings;
}
filterSettings(filter, opts = {}) {
opts.maxResults ??= 100;
opts.onlyOverridden ??= false;
return this.performSearch(filter, opts);
}
@bind
performSearch(filter, opts) {
opts.includeAllCategory ??= true;
let pluginFilter;
if (filter) {
filter = filter
.toLowerCase()
.split(" ")
.filter((word) => {
if (!word.length) {
return false;
}
if (word.startsWith("plugin:")) {
pluginFilter = word.slice("plugin:".length).trim();
return false;
}
return true;
})
.join(" ")
.trim();
}
const matchesGroupedByCategory = [];
const matches = [];
let all;
if (opts.includeAllCategory) {
all = {
nameKey: "all_results",
name: I18n.t("admin.site_settings.categories.all_results"),
siteSettings: [],
};
matchesGroupedByCategory.push(all);
}
const strippedQuery = filter.replace(/[^a-z0-9]/gi, "");
let fuzzyRegex;
let fuzzyRegexGaps;
if (strippedQuery.length > 2) {
fuzzyRegex = new RegExp(strippedQuery.split("").join(".*"), "i");
fuzzyRegexGaps = new RegExp(strippedQuery.split("").join("(.*)"), "i");
}
this.siteSettings.forEach((settingsCategory) => {
let fuzzyMatches = [];
const siteSettings = settingsCategory.siteSettings.filter((item) => {
if (opts.onlyOverridden && !item.get("overridden")) {
return false;
}
if (pluginFilter && item.plugin !== pluginFilter) {
return false;
}
if (filter) {
const setting = item.get("setting").toLowerCase();
let filterResult =
setting.includes(filter) ||
setting.replace(/_/g, " ").includes(filter) ||
item.get("description").toLowerCase().includes(filter) ||
(item.get("keywords") || "")
.replace(/_/g, " ")
.toLowerCase()
.includes(filter.replace(/_/g, " ")) ||
(item.get("value") || "").toString().toLowerCase().includes(filter);
if (!filterResult && fuzzyRegex && fuzzyRegex.test(setting)) {
// Tightens up fuzzy search results a bit.
const fuzzySearchLimiter = 25;
const strippedSetting = setting.replace(/[^a-z0-9]/gi, "");
if (
strippedSetting.length <=
strippedQuery.length + fuzzySearchLimiter
) {
const gapResult = strippedSetting.match(fuzzyRegexGaps);
if (gapResult) {
item.weight = gapResult.filter((gap) => gap !== "").length;
}
fuzzyMatches.push(item);
}
}
return filterResult;
} else {
return true;
}
});
if (fuzzyMatches.length > 0) {
siteSettings.pushObjects(fuzzyMatches);
}
if (siteSettings.length > 0) {
matches.pushObjects(siteSettings);
matchesGroupedByCategory.pushObject({
nameKey: settingsCategory.nameKey,
name: I18n.t(
"admin.site_settings.categories." + settingsCategory.nameKey
),
siteSettings: this.sortSettings(siteSettings),
count: siteSettings.length,
});
}
});
if (opts.includeAllCategory) {
all.siteSettings.pushObjects(matches.slice(0, opts.maxResults));
all.siteSettings = this.sortSettings(all.siteSettings);
all.hasMore = matches.length > opts.maxResults;
all.count = all.hasMore ? `${opts.maxResults}+` : matches.length;
all.maxResults = opts.maxResults;
}
return matchesGroupedByCategory;
}
@bind
sortSettings(settings) {
// Sort the site settings so that fuzzy results are at the bottom
// and ordered by their gap count asc.
return settings.sort((a, b) => {
return (a.weight || 0) - (b.weight || 0);
});
}
}

View File

@ -212,4 +212,29 @@ acceptance("Admin - Site Settings", function (needs) {
);
});
});
test("can perform fuzzy search", async function (assert) {
await visit("/admin/site_settings");
await fillIn("#setting-filter", "top_menu");
assert.dom(".row.setting").exists({ count: 1 });
await fillIn("#setting-filter", "tmenu");
assert.dom(".row.setting").exists({ count: 1 });
// ensures fuzzy search limiter is in place
await fillIn("#setting-filter", "obo");
assert.dom(".row.setting").exists({ count: 1 });
assert.dom(".row.setting").hasText(/onebox/);
// ensures fuzzy search limiter doesn't limit too much
await fillIn("#setting-filter", "blocked_onebox_domains");
assert.dom(".row.setting").exists({ count: 1 });
assert.dom(".row.setting").hasText(/onebox/);
// ensures keyword search is working
await fillIn("#setting-filter", "blah");
assert.dom(".row.setting").exists({ count: 1 });
assert.dom(".row.setting").hasText(/username/);
});
});

View File

@ -21,6 +21,7 @@ export default {
preview: null,
secret: false,
type: "username",
keywords: "blah blah",
},
{
setting: "logo",

View File

@ -1,81 +0,0 @@
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import SiteSetting from "admin/models/site-setting";
module("Unit | Controller | admin-site-settings", function (hooks) {
setupTest(hooks);
test("can perform fuzzy search", async function (assert) {
const controller = this.owner.lookup("controller:admin-site-settings");
const settings = await SiteSetting.findAll();
let results = controller.performSearch("top_menu", settings);
assert.deepEqual(results[0].siteSettings.length, 1);
results = controller.performSearch("tmenu", settings);
assert.deepEqual(results[0].siteSettings.length, 1);
const settings2 = [
{
name: "Required",
nameKey: "required",
siteSettings: [
SiteSetting.create({
description: "",
value: "",
setting: "hpello world",
}),
SiteSetting.create({
description: "",
value: "",
setting: "hello world",
}),
SiteSetting.create({
description: "",
value: "",
setting: "digest_logo",
keywords: "capybara",
}),
SiteSetting.create({
description: "",
value: "",
setting: "pending_users_reminder_delay_minutes",
}),
SiteSetting.create({
description: "",
value: "",
setting: "min_personal_message_post_length",
}),
],
},
];
results = controller.performSearch("hello world", settings2);
assert.deepEqual(results[0].siteSettings.length, 2);
// ensures hello world shows up before fuzzy hpello world
assert.deepEqual(results[0].siteSettings[0].setting, "hello world");
results = controller.performSearch("world", settings2);
assert.deepEqual(results[0].siteSettings.length, 2);
// ensures hello world shows up before fuzzy hpello world with "world" search
assert.deepEqual(results[0].siteSettings[0].setting, "hello world");
// ensures fuzzy search limiter is in place
results = controller.performSearch("digest", settings2);
assert.deepEqual(results[0].siteSettings.length, 1);
assert.deepEqual(results[0].siteSettings[0].setting, "digest_logo");
// ensures fuzzy search limiter doesn't limit too much
results = controller.performSearch("min length", settings2);
assert.strictEqual(results[0].siteSettings.length, 1);
assert.strictEqual(
results[0].siteSettings[0].setting,
"min_personal_message_post_length"
);
// ensures keyword search is working
results = controller.performSearch("capybara", settings2);
assert.deepEqual(results[0].siteSettings.length, 1);
assert.deepEqual(results[0].siteSettings[0].setting, "digest_logo");
});
});

View File

@ -98,3 +98,17 @@
}
}
}
.admin-plugin-filtered-site-settings {
&__filter {
width: 100%;
}
}
.admin-plugin-config-area {
&__settings {
.admin-site-settings-filter-controls {
margin-bottom: 1em;
}
}
}

View File

@ -6,7 +6,15 @@ class Admin::SiteSettingsController < Admin::AdminController
end
def index
render_json_dump(site_settings: SiteSetting.all_settings)
params.permit(:categories, :plugin)
render_json_dump(
site_settings:
SiteSetting.all_settings(
filter_categories: params[:categories],
filter_plugin: params[:plugin],
),
)
end
def update

View File

@ -176,7 +176,13 @@ module SiteSettingExtension
end
# Retrieve all settings
def all_settings(include_hidden: false)
def all_settings(
include_hidden: false,
include_locale_setting: true,
only_overridden: false,
filter_categories: nil,
filter_plugin: nil
)
locale_setting_hash = {
setting: "default_locale",
default: SiteSettings::DefaultsProvider::DEFAULT_LOCALE,
@ -189,12 +195,28 @@ module SiteSettingExtension
translate_names: LocaleSiteSetting.translate_names?,
}
include_locale_setting = false if filter_categories.present? || filter_plugin.present?
defaults
.all(default_locale)
.reject do |setting_name, _|
plugins[name] && !Discourse.plugins_by_name[plugins[name]].configurable?
end
.reject { |setting_name, _| !include_hidden && hidden_settings.include?(setting_name) }
.select do |setting_name, _|
if filter_categories && filter_categories.any?
filter_categories.include?(categories[setting_name])
else
true
end
end
.select do |setting_name, _|
if filter_plugin
plugins[setting_name] == filter_plugin
else
true
end
end
.map do |s, v|
type_hash = type_supervisor.type_hash(s)
default = defaults.get(s, default_locale).to_s
@ -222,7 +244,15 @@ module SiteSettingExtension
opts
end
.unshift(locale_setting_hash)
.select do |setting|
if only_overridden
setting[:value] != setting[:default]
else
true
end
end
.unshift(include_locale_setting && !only_overridden ? locale_setting_hash : nil)
.compact
end
def description(setting)

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
RSpec.describe SiteSettingExtension do
describe "#all_settings" do
it "allows filtering settings by plugin via filter_plugin" do
settings = YAML.safe_load(File.read(Rails.root.join("plugins/chat/config/settings.yml")))
expect(
SiteSetting
.all_settings(include_hidden: true, filter_plugin: "chat")
.map { |s| s[:setting] },
).to match_array(settings["chat"].keys.map(&:to_sym))
end
end
end

View File

@ -171,4 +171,29 @@ RSpec.describe SiteSetting do
expect(settings.test_setting).to eq(value)
end
describe "#all_settings" do
it "does not include the `default_locale` setting if include_locale_setting is false" do
expect(SiteSetting.all_settings.map { |s| s[:setting] }).to include("default_locale")
expect(
SiteSetting.all_settings(include_locale_setting: false).map { |s| s[:setting] },
).not_to include("default_locale")
end
it "does not include the `default_locale` setting if filter_categories are specified" do
expect(
SiteSetting.all_settings(filter_categories: ["branding"]).map { |s| s[:setting] },
).not_to include("default_locale")
end
it "does not include the `default_locale` setting if filter_plugin is specified" do
expect(
SiteSetting.all_settings(filter_plugin: "chat").map { |s| s[:setting] },
).not_to include("default_locale")
end
it "includes only settings for the specified category" do
expect(SiteSetting.all_settings(filter_categories: ["required"]).count).to eq(12)
end
end
end

View File

@ -9,6 +9,23 @@ describe "Admin Site Setting Search", type: :system do
sign_in(admin)
end
it "clears the filter" do
settings_page.visit
settings_page.type_in_search("min personal message post length")
expect(settings_page).to have_n_results(1)
settings_page.clear_search
expect(settings_page).to have_greater_than_n_results(1)
end
it "can show only overridden settings" do
overridden_setting_count = SiteSetting.all_settings(only_overridden: true).length
settings_page.visit
settings_page.toggle_only_show_overridden
assert_selector(".admin-detail .row.setting.overridden", count: overridden_setting_count)
settings_page.toggle_only_show_overridden
expect(settings_page).to have_greater_than_n_results(overridden_setting_count)
end
describe "when searching for keywords" do
it "finds the associated site setting" do
settings_page.visit

View File

@ -45,8 +45,26 @@ module PageObjects
self
end
def clear_search
find("#setting-filter").click
self
end
def toggle_only_show_overridden
find("#setting-filter-toggle-overridden").click
self
end
def has_search_result?(setting)
page.has_selector?("div[data-setting='#{setting}']")
has_css?("div[data-setting='#{setting}']")
end
def has_n_results?(count)
has_css?(".admin-detail .row.setting", count: count)
end
def has_greater_than_n_results?(count)
assert_selector(".admin-detail .row.setting", minimum: count)
end
end
end