From 206be22174b50ef3d6d6a1fa607d0daf29d7f538 Mon Sep 17 00:00:00 2001 From: Finn Bacall Date: Mon, 22 Dec 2025 15:19:34 +0000 Subject: [PATCH 1/9] Initial attempt at toggling features on/off in spaces. #1205 --- app/controllers/spaces_controller.rb | 2 +- app/helpers/spaces_helper.rb | 4 +++ app/models/default_space.rb | 4 +++ app/models/space.rb | 30 +++++++++++++++++++ app/views/layouts/_header.html.erb | 2 +- app/views/spaces/_form.html.erb | 4 +++ config/locales/en.yml | 1 + ...2142740_add_disabled_features_to_spaces.rb | 5 ++++ db/schema.rb | 3 +- 9 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20251222142740_add_disabled_features_to_spaces.rb diff --git a/app/controllers/spaces_controller.rb b/app/controllers/spaces_controller.rb index 8d118e303..0f82b4f16 100644 --- a/app/controllers/spaces_controller.rb +++ b/app/controllers/spaces_controller.rb @@ -76,7 +76,7 @@ def set_space end def space_params - permitted = [:title, :description, :theme, :image, :image_url, { administrator_ids: [] }] + permitted = [:title, :description, :theme, :image, :image_url, { administrator_ids: [] }, { enabled_features: [] }] permitted += [:host] if current_user.is_admin? params.require(:space).permit(*permitted) end diff --git a/app/helpers/spaces_helper.rb b/app/helpers/spaces_helper.rb index 276301526..840d197b2 100644 --- a/app/helpers/spaces_helper.rb +++ b/app/helpers/spaces_helper.rb @@ -3,4 +3,8 @@ module SpacesHelper def spaces_info I18n.t('info.spaces.description') end + + def space_feature_options + Space::FEATURES.map { |f| [t("features.#{f}.short"), f] } + end end diff --git a/app/models/default_space.rb b/app/models/default_space.rb index f0680d3d1..e29fd466a 100644 --- a/app/models/default_space.rb +++ b/app/models/default_space.rb @@ -60,4 +60,8 @@ def default? def administrators User.with_role('admin') end + + def feature_enabled?(feature) + TeSS::Config.feature[feature] + end end diff --git a/app/models/space.rb b/app/models/space.rb index fc7049035..4e9b93d6f 100644 --- a/app/models/space.rb +++ b/app/models/space.rb @@ -1,4 +1,6 @@ class Space < ApplicationRecord + FEATURES = %w[events materials elearning_materials learning_paths workflows collections trainers content_providers nodes].freeze + include PublicActivity::Common include LogParameterChanges @@ -15,6 +17,7 @@ class Space < ApplicationRecord has_many :administrators, through: :administrator_roles, source: :user, class_name: 'User' validates :theme, inclusion: { in: TeSS::Config.themes.keys, allow_blank: true } + validate :disabled_features_valid? has_image(placeholder: TeSS::Config.placeholder['content_provider']) @@ -45,4 +48,31 @@ def default? def users_with_role(role) space_role_users.joins(:space_roles).where(space_roles: { key: role }) end + + def feature_enabled?(feature) + if FEATURES.include?(feature) + !disabled_features.include?(feature) + else + TeSS::Config.feature[feature] + end + end + + def enabled_features= features + self.disabled_features = (FEATURES - features) + end + + def enabled_features + (FEATURES - disabled_features) + end + + private + + def disabled_features_valid? + disabled_features.each do |feature| + next if feature.blank? + unless FEATURES.include?(feature) + errors.add(:disabled_features, :inclusion) + end + end + end end diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 5eff2026d..4a7ad65b7 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -54,7 +54,7 @@ { feature: 'nodes', link: nodes_path }, { feature: 'spaces', link: spaces_path } ].select do |t| - t[:feature] == 'about' || TeSS::Config.feature[t[:feature]] + t[:feature] == 'about' || Space.current_space.feature_enabled?(t[:feature]) end.sort_by do |t| TeSS::Config.site['tab_order'].index(t[:feature]) || 99 end diff --git a/app/views/spaces/_form.html.erb b/app/views/spaces/_form.html.erb index aa2de5cbf..c36b24a79 100644 --- a/app/views/spaces/_form.html.erb +++ b/app/views/spaces/_form.html.erb @@ -19,6 +19,10 @@ id_field: :id, existing_items_method: :administrators %> +
+ <%= f.input :enabled_features, label: t('features.enabled'), collection: space_feature_options, as: :check_boxes %> +
+
<%= f.submit(class: 'btn btn-primary') %> <%= link_to t('.cancel', default: t("helpers.links.cancel")), diff --git a/config/locales/en.yml b/config/locales/en.yml index d5650cfb0..cf12c411a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,5 +1,6 @@ en: features: + enabled: Features enabled about: short: About long: About diff --git a/db/migrate/20251222142740_add_disabled_features_to_spaces.rb b/db/migrate/20251222142740_add_disabled_features_to_spaces.rb new file mode 100644 index 000000000..a07e19b4e --- /dev/null +++ b/db/migrate/20251222142740_add_disabled_features_to_spaces.rb @@ -0,0 +1,5 @@ +class AddDisabledFeaturesToSpaces < ActiveRecord::Migration[7.2] + def change + add_column :spaces, :disabled_features, :string, array: true, default: [] + end +end diff --git a/db/schema.rb b/db/schema.rb index 769407996..b3a246afe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_12_01_143501) do +ActiveRecord::Schema[7.2].define(version: 2025_12_22_142740) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -511,6 +511,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.text "image_url" + t.string "disabled_features", default: [], array: true t.index ["host"], name: "index_spaces_on_host", unique: true t.index ["user_id"], name: "index_spaces_on_user_id" end From b9a408092fb6926b7b8b29c04c85a7a148d85c38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:30:10 +0000 Subject: [PATCH 2/9] Initial plan From a3c92b9d6f6518c8e72c1281f80bb9ea7c19aa16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:34:36 +0000 Subject: [PATCH 3/9] Add comprehensive tests for Space disabled_features field Co-authored-by: fbacall <503373+fbacall@users.noreply.github.com> --- test/controllers/static_controller_test.rb | 174 +++++++++++++++++++++ test/models/space_test.rb | 83 ++++++++++ 2 files changed, 257 insertions(+) diff --git a/test/controllers/static_controller_test.rb b/test/controllers/static_controller_test.rb index 7da77d111..076884e94 100644 --- a/test/controllers/static_controller_test.rb +++ b/test/controllers/static_controller_test.rb @@ -520,4 +520,178 @@ class StaticControllerTest < ActionController::TestCase end end end + + test 'should respect space disabled_features when displaying tabs' do + space = spaces(:plants) + space.disabled_features = ['events', 'materials'] + space.save! + + features = { + 'events': true, + 'materials': true, + 'elearning_materials': true, + 'workflows': true, + 'collections': true, + 'content_providers': true, + 'trainers': true, + 'nodes': true, + 'spaces': true + } + + with_settings(feature: features) do + with_host('plants.mytess.training') do + get :home + + # These should NOT appear because they're disabled for this space + assert_select 'ul.nav.navbar-nav' do + assert_select 'li a[href=?]', events_path, count: 0 + assert_select 'li a[href=?]', materials_path, count: 0 + end + + # These should still appear because they're not disabled + assert_select 'ul.nav.navbar-nav' do + assert_select 'li a[href=?]', about_path + assert_select 'li a[href=?]', elearning_materials_path + assert_select 'li a[href=?]', workflows_path + assert_select 'li a[href=?]', collections_path + end + end + end + end + + test 'space disabled_features do not affect default space' do + space = spaces(:plants) + space.disabled_features = ['events', 'materials'] + space.save! + + features = { + 'events': true, + 'materials': true, + 'elearning_materials': true, + 'workflows': true, + 'collections': true, + 'content_providers': true, + 'trainers': true, + 'nodes': true, + 'spaces': true + } + + with_settings(feature: features) do + # Access the default space (not plants) + get :home + + # All features should appear in the default space + assert_select 'ul.nav.navbar-nav' do + assert_select 'li a[href=?]', about_path + assert_select 'li a[href=?]', events_path + assert_select 'li a[href=?]', materials_path + assert_select 'li a[href=?]', elearning_materials_path + assert_select 'li a[href=?]', workflows_path + assert_select 'li a[href=?]', collections_path + end + end + end + + test 'different spaces can have different disabled features' do + plants_space = spaces(:plants) + plants_space.disabled_features = ['events'] + plants_space.save! + + astro_space = spaces(:astro) + astro_space.disabled_features = ['materials'] + astro_space.save! + + features = { + 'events': true, + 'materials': true, + 'workflows': true, + 'spaces': true + } + + with_settings(feature: features) do + # Check plants space - events disabled + with_host('plants.mytess.training') do + get :home + + assert_select 'ul.nav.navbar-nav' do + assert_select 'li a[href=?]', events_path, count: 0 + assert_select 'li a[href=?]', materials_path + assert_select 'li a[href=?]', workflows_path + end + end + + # Check astro space - materials disabled + with_host('space.mytess.training') do + get :home + + assert_select 'ul.nav.navbar-nav' do + assert_select 'li a[href=?]', events_path + assert_select 'li a[href=?]', materials_path, count: 0 + assert_select 'li a[href=?]', workflows_path + end + end + end + end + + test 'space with no disabled features shows all enabled global features' do + space = spaces(:plants) + space.disabled_features = [] + space.save! + + features = { + 'events': true, + 'materials': true, + 'elearning_materials': true, + 'workflows': true, + 'collections': true, + 'content_providers': true, + 'trainers': true, + 'nodes': true, + 'spaces': true + } + + with_settings(feature: features) do + with_host('plants.mytess.training') do + get :home + + # All globally enabled features should appear + assert_select 'ul.nav.navbar-nav' do + assert_select 'li a[href=?]', about_path + assert_select 'li a[href=?]', events_path + assert_select 'li a[href=?]', materials_path + assert_select 'li a[href=?]', elearning_materials_path + assert_select 'li a[href=?]', workflows_path + assert_select 'li a[href=?]', collections_path + end + end + end + end + + test 'space disabled features work with directory tabs' do + space = spaces(:plants) + space.disabled_features = ['content_providers'] + space.save! + + features = { + 'events': true, + 'materials': true, + 'content_providers': true, + 'trainers': true, + 'nodes': true, + 'spaces': true + } + + with_settings(feature: features, site: { tab_order: [], directory_tabs: ['content_providers', 'trainers', 'nodes'] }) do + with_host('plants.mytess.training') do + get :home + + # content_providers should not appear even in directory menu + assert_select 'li.dropdown.directory-menu' do + assert_select 'li a[href=?]', content_providers_path, count: 0 + assert_select 'li a[href=?]', trainers_path + assert_select 'li a[href=?]', nodes_path + end + end + end + end end diff --git a/test/models/space_test.rb b/test/models/space_test.rb index 7db2d15a1..91befd709 100644 --- a/test/models/space_test.rb +++ b/test/models/space_test.rb @@ -31,4 +31,87 @@ class SpaceTest < ActiveSupport::TestCase assert_empty @space.administrators end + + test 'disabled_features defaults to empty array' do + new_space = Space.new(title: 'Test Space', host: 'test.example.com', user: users(:regular_user)) + assert_equal [], new_space.disabled_features + end + + test 'disabled_features can be set to valid features' do + @space.disabled_features = ['events', 'materials'] + assert @space.valid? + assert_equal ['events', 'materials'], @space.disabled_features + end + + test 'disabled_features rejects invalid features' do + @space.disabled_features = ['invalid_feature', 'events'] + assert_not @space.valid? + assert_includes @space.errors[:disabled_features], :inclusion + end + + test 'disabled_features allows empty strings' do + @space.disabled_features = ['events', '', 'materials'] + assert @space.valid? + end + + test 'feature_enabled? returns true for enabled features' do + @space.disabled_features = ['materials'] + assert @space.feature_enabled?('events') + assert @space.feature_enabled?('workflows') + end + + test 'feature_enabled? returns false for disabled features' do + @space.disabled_features = ['events', 'materials'] + assert_not @space.feature_enabled?('events') + assert_not @space.feature_enabled?('materials') + end + + test 'feature_enabled? returns true when no features are disabled' do + @space.disabled_features = [] + Space::FEATURES.each do |feature| + assert @space.feature_enabled?(feature), "Feature #{feature} should be enabled" + end + end + + test 'feature_enabled? falls back to TeSS::Config for non-space features' do + @space.disabled_features = [] + # Test with a feature that's not in Space::FEATURES + with_settings(feature: { 'custom_feature' => true }) do + assert @space.feature_enabled?('custom_feature') + end + + with_settings(feature: { 'custom_feature' => false }) do + assert_not @space.feature_enabled?('custom_feature') + end + end + + test 'enabled_features returns all features except disabled ones' do + @space.disabled_features = ['events', 'materials'] + enabled = @space.enabled_features + + assert_not_includes enabled, 'events' + assert_not_includes enabled, 'materials' + assert_includes enabled, 'workflows' + assert_includes enabled, 'collections' + end + + test 'enabled_features= sets disabled_features to complement' do + @space.enabled_features = ['events', 'materials'] + + assert_includes @space.disabled_features, 'workflows' + assert_includes @space.disabled_features, 'collections' + assert_includes @space.disabled_features, 'elearning_materials' + assert_not_includes @space.disabled_features, 'events' + assert_not_includes @space.disabled_features, 'materials' + end + + test 'enabled_features= with empty array disables all features' do + @space.enabled_features = [] + assert_equal Space::FEATURES.sort, @space.disabled_features.sort + end + + test 'enabled_features= with all features enables all features' do + @space.enabled_features = Space::FEATURES + assert_equal [], @space.disabled_features + end end From 4e690d7c04bc44a0d7f16b7442f0cc31043c75c3 Mon Sep 17 00:00:00 2001 From: Finn Bacall Date: Mon, 22 Dec 2025 18:04:30 +0000 Subject: [PATCH 4/9] Test fixes --- test/fixtures/spaces.yml | 2 ++ test/models/space_test.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/fixtures/spaces.yml b/test/fixtures/spaces.yml index a87549fb4..51367a58e 100644 --- a/test/fixtures/spaces.yml +++ b/test/fixtures/spaces.yml @@ -6,7 +6,9 @@ plants: astro: title: TeSS Space Community host: space.mytess.training + user: curator other: title: Other TeSS Community host: other.mytess.training + user: curator diff --git a/test/models/space_test.rb b/test/models/space_test.rb index 91befd709..58460bbac 100644 --- a/test/models/space_test.rb +++ b/test/models/space_test.rb @@ -46,7 +46,7 @@ class SpaceTest < ActiveSupport::TestCase test 'disabled_features rejects invalid features' do @space.disabled_features = ['invalid_feature', 'events'] assert_not @space.valid? - assert_includes @space.errors[:disabled_features], :inclusion + assert @space.errors.added?(:disabled_features, :inclusion) end test 'disabled_features allows empty strings' do From 4c4a8c71c4e540db74886d7ef34d40ae8bb720ed Mon Sep 17 00:00:00 2001 From: Finn Bacall Date: Mon, 22 Dec 2025 18:14:29 +0000 Subject: [PATCH 5/9] Add extra users to own the other spaces --- test/fixtures/spaces.yml | 4 ++-- test/fixtures/users.yml | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/test/fixtures/spaces.yml b/test/fixtures/spaces.yml index 51367a58e..903f10857 100644 --- a/test/fixtures/spaces.yml +++ b/test/fixtures/spaces.yml @@ -6,9 +6,9 @@ plants: astro: title: TeSS Space Community host: space.mytess.training - user: curator + user: astro_owner other: title: Other TeSS Community host: other.mytess.training - user: curator + user: other_owner diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index e36adb440..8e006a238 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -172,3 +172,15 @@ space_admin: email: 'plantboss@example.com' encrypted_password: <%= Devise::Encryptor.digest(User, 'plantsrule') %> confirmed_at: <%= Time.zone.now %> + +astro_owner: + username: 'astro_boss' + email: 'astroboss@example.com' + encrypted_password: <%= Devise::Encryptor.digest(User, 'spacerules') %> + confirmed_at: <%= Time.zone.now %> + +other_owner: + username: 'other_boss' + email: 'otherboss@example.com' + encrypted_password: <%= Devise::Encryptor.digest(User, 'password') %> + confirmed_at: <%= Time.zone.now %> From b893d7fc36299e01596bef3336a9dbb42d6b92db Mon Sep 17 00:00:00 2001 From: Finn Bacall Date: Mon, 22 Dec 2025 19:06:38 +0000 Subject: [PATCH 6/9] Use new `Space#feature_enabled?` method for checking if a feature is used --- app/views/about/_about_nav.html.erb | 2 +- app/views/about/_links.html.erb | 2 +- app/views/about/developers.erb | 6 +-- app/views/about/tess.html.erb | 6 +-- app/views/collections/show.html.erb | 12 +++--- app/views/content_providers/_form.html.erb | 2 +- .../_content_provider_sidebar.html.erb | 2 +- app/views/content_providers/show.html.erb | 8 ++-- app/views/events/_form.html.erb | 2 +- app/views/learning_path_topics/show.html.erb | 8 ++-- app/views/learning_paths/_form.html.erb | 2 +- app/views/materials/_form.html.erb | 38 +++++++++---------- .../partials/_associated_node_info.html.erb | 2 +- app/views/nodes/show.html.erb | 12 +++--- .../static/home/_provider_carousel.html.erb | 2 +- app/views/trainers/index.html.erb | 2 +- app/views/users/_form.html.erb | 2 +- app/views/users/show.html.erb | 14 +++---- 18 files changed, 62 insertions(+), 62 deletions(-) diff --git a/app/views/about/_about_nav.html.erb b/app/views/about/_about_nav.html.erb index 8350deb36..2ce1ae21c 100644 --- a/app/views/about/_about_nav.html.erb +++ b/app/views/about/_about_nav.html.erb @@ -24,7 +24,7 @@ - <% if TeSS::Config.feature['learning_paths'] %> + <% if Space.current_space.feature_enabled?('learning_paths') %>