+ {{#if this.shouldRender}}
+
+ {{htmlSafe this.component.text}}
+
+ {{/if}}
+
+}
diff --git a/settings.yml b/settings.yml
index 95c519c..68b0993 100644
--- a/settings.yml
+++ b/settings.yml
@@ -72,6 +72,10 @@ custom_text_block:
class:
type: string
required: false
+ id:
+ type: string
+ required: false
+ description: The id to apply to the text block
group_action:
type: enum
required: true
diff --git a/spec/support/theme_helpers.rb b/spec/support/theme_helpers.rb
new file mode 100644
index 0000000..37221c9
--- /dev/null
+++ b/spec/support/theme_helpers.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ThemeHelpers
+ # Configure a custom text block entry on the uploaded theme component
+ # theme - the uploaded theme component (returned by upload_theme_component)
+ # entry - a Hash representing the text block entry
+ def configure_text_block(theme, entry)
+ theme.update_setting(:custom_text_block, [entry])
+ theme.save!
+ end
+
+ # Configure a button entry on the uploaded theme component
+ def configure_button(theme, entry)
+ theme.update_setting(:buttons, [entry])
+ theme.save!
+ end
+end
diff --git a/spec/system/button_visibility_spec.rb b/spec/system/button_visibility_spec.rb
new file mode 100644
index 0000000..4590fd7
--- /dev/null
+++ b/spec/system/button_visibility_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require_relative "page_objects/components/button_component"
+require_relative "../support/theme_helpers"
+
+include ThemeHelpers
+
+RSpec.describe "Button visibility", type: :system do
+ fab!(:group1) { Fabricate(:group, name: "group1") }
+ fab!(:member) { Fabricate(:user, groups: [group1]) }
+ let!(:theme) { upload_theme_component }
+
+ let(:button) { PageObjects::Components::ButtonComponent.new }
+
+ # use ThemeHelpers#configure_button(theme, entry)
+
+ it "shows the button for members when group_action is 'show'" do
+ configure_button(
+ theme,
+ {
+ "name" => "button 1",
+ "text" => "Click me",
+ "title" => "Click this",
+ "url" => "https://example.com",
+ "class" => "test-button",
+ "icon" => "triangle-exclamation",
+ "outlet" => "top-notices",
+ "group_action" => "show",
+ "groups" => [group1.id],
+ },
+ )
+
+ sign_in(member)
+ visit "/"
+
+ expect(button.visible?).to be true
+ expect(button.label_text).to include("Click me")
+ expect(button.href).to include("https://example.com")
+ end
+
+ it "hides the button for anonymous visitors when group_action is 'show'" do
+ configure_button(
+ theme,
+ {
+ "name" => "button 1",
+ "text" => "Click me",
+ "title" => "Click this",
+ "url" => "https://example.com",
+ "class" => "test-button",
+ "icon" => "triangle-exclamation",
+ "outlet" => "top-notices",
+ "group_action" => "show",
+ "groups" => [group1.id],
+ },
+ )
+
+ visit "/"
+ expect(button.not_visible?).to be true
+ end
+
+ it "hides the button for group members when group_action is 'hide'" do
+ configure_button(
+ theme,
+ {
+ "name" => "button 1",
+ "text" => "Hidden",
+ "url" => "https://example.com",
+ "class" => "test-button",
+ "outlet" => "top-notices",
+ "group_action" => "hide",
+ "groups" => [group1.id],
+ },
+ )
+
+ sign_in(member)
+ visit "/"
+ expect(button.not_visible?).to be true
+ end
+
+ it "renders the configured id attribute when visible" do
+ configure_button(
+ theme,
+ {
+ "name" => "button 1",
+ "text" => "Has ID",
+ "url" => "https://example.com",
+ "class" => "test-button",
+ "id" => "my-button",
+ "outlet" => "top-notices",
+ "group_action" => "show",
+ "groups" => [group1.id],
+ },
+ )
+
+ sign_in(member)
+ visit "/"
+ expect(button.id).to eq("my-button")
+ end
+end
diff --git a/spec/system/page_objects/components/base.rb b/spec/system/page_objects/components/base.rb
new file mode 100644
index 0000000..5873cd0
--- /dev/null
+++ b/spec/system/page_objects/components/base.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require "capybara"
+
+module PageObjects
+ module Components
+ class Base
+ include Capybara::DSL
+
+ # Optional scope can be provided to narrow down searches
+ def initialize(scope = nil)
+ @scope = scope
+ end
+
+ # Subclasses should override COMPONENT_SELECTOR or component_selector
+ def component_selector
+ if self.class.const_defined?(:COMPONENT_SELECTOR)
+ self.class.const_get(:COMPONENT_SELECTOR)
+ else
+ @scope
+ end
+ end
+
+ def has_css?(selector, **opts)
+ page.has_css?(selector, **opts)
+ end
+
+ def has_no_css?(selector, **opts)
+ page.has_no_css?(selector, **opts)
+ end
+
+ def find_css(selector)
+ page.find(selector)
+ end
+
+ def text(selector = nil)
+ selector ? find_css(selector).text : find_css(component_selector).text
+ end
+
+ def attribute(name, selector = nil)
+ find_css(selector || component_selector)[name]
+ end
+
+ def click(selector = nil)
+ find_css(selector || component_selector).click
+ end
+ end
+ end
+end
diff --git a/spec/system/page_objects/components/button_component.rb b/spec/system/page_objects/components/button_component.rb
new file mode 100644
index 0000000..19b8b24
--- /dev/null
+++ b/spec/system/page_objects/components/button_component.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Components
+ class ButtonComponent < PageObjects::Components::Base
+ COMPONENT_SELECTOR = ".btn-custom"
+
+ def label_text
+ # DButton may render text inside the component; return its visible text
+ text(COMPONENT_SELECTOR)
+ end
+
+ def href
+ attribute_value("href")
+ end
+
+ def id
+ attribute_value("id")
+ end
+ def visible?(opts = {})
+ has_css?(COMPONENT_SELECTOR, **opts)
+ end
+
+ def not_visible?(opts = {})
+ has_no_css?(COMPONENT_SELECTOR, **opts)
+ end
+
+ private
+
+ # Try to delegate to Base#attribute if available, otherwise fall back
+ # to a direct Capybara lookup. This is defensive: test environments
+ # may load PageObjects differently so the Base helper might not be
+ # present at the time of invocation.
+ def attribute_value(name)
+ begin
+ # Call Base#attribute directly if it's defined there
+ base_attr = PageObjects::Components::Base.instance_method(:attribute)
+ base_attr.bind(self).call(name, COMPONENT_SELECTOR)
+ rescue NameError, NoMethodError
+ # Fallback: find the element and return the attribute
+ page.find(COMPONENT_SELECTOR)[name]
+ end
+ end
+ end
+ end
+end
diff --git a/spec/system/page_objects/components/text_block_component.rb b/spec/system/page_objects/components/text_block_component.rb
new file mode 100644
index 0000000..739bbd4
--- /dev/null
+++ b/spec/system/page_objects/components/text_block_component.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Components
+ class TextBlockComponent < PageObjects::Components::Base
+ COMPONENT_SELECTOR = ".custom-component"
+
+ def component_selector
+ COMPONENT_SELECTOR
+ end
+
+ def has_content?(content)
+ has_css?(COMPONENT_SELECTOR, text: content)
+ end
+
+ def visible?(opts = {})
+ has_css?(COMPONENT_SELECTOR, **opts)
+ end
+
+ def not_visible?(opts = {})
+ has_no_css?(COMPONENT_SELECTOR, **opts)
+ end
+
+ def attribute(name)
+ attribute_value =
+ begin
+ attribute(name, COMPONENT_SELECTOR)
+ rescue StandardError
+ nil
+ end
+ # Fall back to finding the element directly if Base#attribute isn't available
+ attribute_value ||= page.find(COMPONENT_SELECTOR)[name]
+ attribute_value
+ end
+ end
+ end
+end
diff --git a/spec/system/text_block_html_safe_spec.rb b/spec/system/text_block_html_safe_spec.rb
new file mode 100644
index 0000000..be5b48d
--- /dev/null
+++ b/spec/system/text_block_html_safe_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require_relative "page_objects/components/text_block_component"
+require_relative "../support/theme_helpers"
+
+include ThemeHelpers
+
+RSpec.describe "Text block HTML-safe rendering", type: :system do
+ fab!(:group1) { Fabricate(:group, name: "group1") }
+ fab!(:member) { Fabricate(:user, groups: [group1]) }
+ let!(:theme) { upload_theme_component }
+
+ it "renders HTML tags unescaped when the text contains HTML" do
+ configure_text_block(
+ theme,
+ {
+ "name" => "text_block html",
+ "text" => "This is