From 42dde8d62c7121f7c167add6ef0c0abdf0f91be8 Mon Sep 17 00:00:00 2001 From: Richard Lences <82523201+richardlences@users.noreply.github.com> Date: Thu, 5 Dec 2024 18:04:16 +0100 Subject: [PATCH] Feature flags GUI (#513) Feature flags --------- Co-authored-by: Jano Suchal --- .../feature_flags_list_row_component.html.erb | 21 ++++++++++++ .../feature_flags_list_row_component.rb | 6 ++++ app/components/common/icon_component.rb | 3 ++ .../admin/feature_flags_controller.rb | 34 +++++++++++++++++++ app/lib/sidebar_menu.rb | 5 +-- app/models/tenant.rb | 15 ++++++-- app/policies/admin/feature_flag_policy.rb | 24 +++++++++++++ app/views/admin/feature_flags/index.html.erb | 12 +++++++ config/locales/sk.yml | 19 +++++++++++ config/routes.rb | 1 + .../admin/feature_flags_management_test.rb | 19 +++++++++++ 11 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 app/components/admin/feature_flags/feature_flags_list_row_component.html.erb create mode 100644 app/components/admin/feature_flags/feature_flags_list_row_component.rb create mode 100644 app/controllers/admin/feature_flags_controller.rb create mode 100644 app/policies/admin/feature_flag_policy.rb create mode 100644 app/views/admin/feature_flags/index.html.erb create mode 100644 test/system/admin/feature_flags_management_test.rb diff --git a/app/components/admin/feature_flags/feature_flags_list_row_component.html.erb b/app/components/admin/feature_flags/feature_flags_list_row_component.html.erb new file mode 100644 index 000000000..310de9247 --- /dev/null +++ b/app/components/admin/feature_flags/feature_flags_list_row_component.html.erb @@ -0,0 +1,21 @@ +
+
+
+
+
+ <%= t "feature_flags.#{@feature_flag}.name"%> +
+
+ <%= t "feature_flags.#{@feature_flag}.description"%> +
+
+
+
+ <%= form_with model: Current.tenant, url: admin_tenant_feature_flag_path(Current.tenant, @feature_flag), title: "Toggle feature state", method: :patch do |form| %> + <%= form.hidden_field :enabled, value: !@enabled %> + <%= form.button name: @feature_flag, class: "#{@enabled ? "bg-indigo-600" : "bg-gray-200"} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2", role: :switch, aria: { checked: @enabled } do %> + Use setting + + <% end %> + <% end %> +
diff --git a/app/components/admin/feature_flags/feature_flags_list_row_component.rb b/app/components/admin/feature_flags/feature_flags_list_row_component.rb new file mode 100644 index 000000000..65e5d154f --- /dev/null +++ b/app/components/admin/feature_flags/feature_flags_list_row_component.rb @@ -0,0 +1,6 @@ +class Admin::FeatureFlags::FeatureFlagsListRowComponent < ViewComponent::Base + def initialize(flag, enabled_features) + @feature_flag = flag + @enabled = enabled_features.include?(flag) + end +end diff --git a/app/components/common/icon_component.rb b/app/components/common/icon_component.rb index 2fc479d0f..22274b45e 100644 --- a/app/components/common/icon_component.rb +++ b/app/components/common/icon_component.rb @@ -32,6 +32,9 @@ class IconComponent < ViewComponent::Base "funnel-slash" => "M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3ZM2 2l20 20", "tag-slash" => "M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z M6 6h.008v.008H6V6ZM3 21l18-18", "inbox-stack" => "m7.875 14.25 1.214 1.942a2.25 2.25 0 0 0 1.908 1.058h2.006c.776 0 1.497-.4 1.908-1.058l1.214-1.942M2.41 9h4.636a2.25 2.25 0 0 1 1.872 1.002l.164.246a2.25 2.25 0 0 0 1.872 1.002h2.092a2.25 2.25 0 0 0 1.872-1.002l.164-.246A2.25 2.25 0 0 1 16.954 9h4.636M2.41 9a2.25 2.25 0 0 0-.16.832V12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 12V9.832c0-.287-.055-.57-.16-.832M2.41 9a2.25 2.25 0 0 1 .382-.632l3.285-3.832a2.25 2.25 0 0 1 1.708-.786h8.43c.657 0 1.281.287 1.709.786l3.284 3.832c.163.19.291.404.382.632M4.5 20.25h15A2.25 2.25 0 0 0 21.75 18v-2.625c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125V18a2.25 2.25 0 0 0 2.25 2.25Z", + "flag" => "M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5", + "puzzle-piece" => "M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.39 48.39 0 0 1-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 0 1-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 0 0-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 0 1-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 0 0 .657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 0 1-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 0 0 5.427-.63 48.05 48.05 0 0 0 .582-4.717.532.532 0 0 0-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 0 0 .658-.663 48.422 48.422 0 0 0-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 0 1-.61-.58v0Z" + }.freeze def initialize(icon, classes: "", stroke_width: 1.5) diff --git a/app/controllers/admin/feature_flags_controller.rb b/app/controllers/admin/feature_flags_controller.rb new file mode 100644 index 000000000..3e48de7ff --- /dev/null +++ b/app/controllers/admin/feature_flags_controller.rb @@ -0,0 +1,34 @@ +class Admin::FeatureFlagsController < ApplicationController + before_action :set_tenant + + def index + authorize([:admin, :feature_flag]) + if params.include?(:labs) + @feature_flags = @tenant.list_all_features + else + @feature_flags = @tenant.list_available_features + end + @enabled_features = @tenant.feature_flags + end + + def update + authorize([:admin, :feature_flag]) + if feature_flags_params[:enabled] == "true" + @tenant.feature_flags << params[:id] + else + @tenant.feature_flags.delete(params[:id]) + end + @tenant.save! + redirect_to admin_tenant_feature_flags_path + end + + private + + def set_tenant + @tenant = policy_scope([:admin, :feature_flag]).find(params[:tenant_id]) + end + + def feature_flags_params + params.require(:tenant).permit(:enabled) + end +end diff --git a/app/lib/sidebar_menu.rb b/app/lib/sidebar_menu.rb index b089d8083..0dd7dc4e1 100644 --- a/app/lib/sidebar_menu.rb +++ b/app/lib/sidebar_menu.rb @@ -11,7 +11,7 @@ def initialize(controller, action, parameters = nil) private def initial_structure(controller, _action) - return admin_menu + site_admin_menu if controller.in? %w[groups users tags tag_groups automation_rules boxes api_connections filters automation_webhooks] + return admin_menu + site_admin_menu if controller.in? %w[groups users tags tag_groups automation_rules boxes api_connections filters automation_webhooks feature_flags] default_main_menu end @@ -40,7 +40,8 @@ def admin_menu TW::SidebarMenuItemComponent.new(name: 'API Prepojenia', url: admin_tenant_api_connections_path(Current.tenant), icon: Icons::RectangleStackComponent.new), TW::SidebarMenuItemComponent.new(name: 'Skupiny', url: admin_tenant_groups_path(Current.tenant), icon: Icons::UserGroupsComponent.new), TW::SidebarMenuItemComponent.new(name: 'Štítky', url: admin_tenant_tags_path(Current.tenant), icon: Icons::TagComponent.new), - TW::SidebarMenuItemComponent.new(name: 'Integrácie', url: admin_tenant_automation_webhooks_path(Current.tenant), icon: Common::IconComponent.new("code-bracket")) + TW::SidebarMenuItemComponent.new(name: 'Integrácie', url: admin_tenant_automation_webhooks_path(Current.tenant), icon: Common::IconComponent.new("code-bracket")), + TW::SidebarMenuItemComponent.new(name: 'Funkcie', url: admin_tenant_feature_flags_path(Current.tenant), icon: Common::IconComponent.new("puzzle-piece")) ] end diff --git a/app/models/tenant.rb b/app/models/tenant.rb index 55b22f53f..1ba3c7958 100644 --- a/app/models/tenant.rb +++ b/app/models/tenant.rb @@ -44,14 +44,15 @@ class Tenant < ApplicationRecord validates_presence_of :name - AVAILABLE_FEATURE_FLAGS = [:audit_log, :archive, :api, :message_draft_import, :fs_api, :fs_sync] + AVAILABLE_FEATURE_FLAGS = [:audit_log, :archive, :api, :fs_sync] + ALL_FEATURE_FLAGS = [:audit_log, :archive, :api, :message_draft_import, :fs_api, :fs_sync] def draft_tag! draft_tag || raise(ActiveRecord::RecordNotFound, "`DraftTag` not found in tenant: #{id}") end def signed_externally_tag! - signed_externally_tag || raise(ActiveRecord::RecordNotFound, "`SignedExternallyTag` not found in tenant: #{self.id}") + signed_externally_tag || raise(ActiveRecord::RecordNotFound, "`SignedExternallyTag` not found in tenant: #{id}") end def signature_requested_tag! @@ -67,7 +68,7 @@ def user_signature_tags end def feature_enabled?(feature) - raise "Unknown feature #{feature}" unless feature.in? AVAILABLE_FEATURE_FLAGS + raise "Unknown feature #{feature}" unless feature.in? ALL_FEATURE_FLAGS feature.to_s.in? feature_flags end @@ -88,6 +89,14 @@ def disable_feature(feature) save! end + def list_available_features + AVAILABLE_FEATURE_FLAGS + end + + def list_all_features + ALL_FEATURE_FLAGS + end + def make_admins_see_everything! everything_tag.groups << admin_group end diff --git a/app/policies/admin/feature_flag_policy.rb b/app/policies/admin/feature_flag_policy.rb new file mode 100644 index 000000000..40d493140 --- /dev/null +++ b/app/policies/admin/feature_flag_policy.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Admin::FeatureFlagPolicy < ApplicationPolicy + attr_reader :user, :tenant + + def initialize(user, tenant) + @user = user + @tenant = tenant + end + + class Scope < Scope + def resolve + Tenant.where(id: @user.tenant) + end + end + + def index? + @user.admin? + end + + def update? + @user.admin? + end +end diff --git a/app/views/admin/feature_flags/index.html.erb b/app/views/admin/feature_flags/index.html.erb new file mode 100644 index 000000000..f89f26bd2 --- /dev/null +++ b/app/views/admin/feature_flags/index.html.erb @@ -0,0 +1,12 @@ +
+
+
+
Aktivácia rozšírení
+
+ <% @feature_flags.each do |flag| %> +
+ <%= render Admin::FeatureFlags::FeatureFlagsListRowComponent.new(flag.to_s, @enabled_features) %> +
+ <% end %> +
+
diff --git a/config/locales/sk.yml b/config/locales/sk.yml index 18387066e..8b86a5cb9 100644 --- a/config/locales/sk.yml +++ b/config/locales/sk.yml @@ -341,3 +341,22 @@ sk: notifications: header: "Žiadne notifikácie" description: "Nemáte žiadne notifikácie." + feature_flags: + api: + name: "API" + description: "Prístup k vybraným funkciám systému a dátam cez API" + archive: + name: "Archive" + description: "Archivácia správ" + audit_log: + name: "Audit log" + description: "Zaznamenávanie a prezeranie auditných záznamov o činnosti používateľov" + fs_api: + name: "API finančnej správy" + description: "Funkčnosť prepojenia s finančnou správou" + fs_sync: + name: "Synchronizácia schránky z finančnej správy" + description: "Synchronizácia schránky z finančnej správy" + message_draft_import: + name: "Import správ" + description: "Funkcionalita pre hromadné zasielanie správ" diff --git a/config/routes.rb b/config/routes.rb index 5e230a182..ead461a48 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,6 +37,7 @@ end resources :users + resources :feature_flags, only: [:index, :update] resources :boxes, only: :index namespace :boxes do diff --git a/test/system/admin/feature_flags_management_test.rb b/test/system/admin/feature_flags_management_test.rb new file mode 100644 index 000000000..dbbc5434c --- /dev/null +++ b/test/system/admin/feature_flags_management_test.rb @@ -0,0 +1,19 @@ +require "application_system_test_case" + +class FeatureFlagsManagementTest < ApplicationSystemTestCase + setup do + sign_in_as(:admin) + visit root_path + click_link "Nastavenia" + click_link "Funkcie" + end + + test "admin can enable and disable a feature" do + available_features = users(:admin).tenant.list_available_features + enabled = users(:admin).tenant.feature_enabled?(available_features[0]) + click_button available_features[0] + assert_button available_features[0] + users(:admin).tenant.reload + assert_not_equal enabled, users(:admin).tenant.feature_enabled?(available_features[0]) + end +end