Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
To find plugin outlets, run `enableDevTools()` in the javascript console.

Demonstrates [Objects type for theme settings](https://meta.discourse.org/t/objects-type-for-theme-setting/305009) and how to use `api.renderInOutlet` to render a Ember (glimmer?) component anywhere you want.
See `TESTING.md` for instructions on running the Discourse-style system specs for this theme.
33 changes: 33 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Testing (Discourse-style system specs)

This theme provides RSpec/Capybara system specs that run inside the Discourse
test harness (recommended for end-to-end testing of themes and theme
components). We use the `discourse_theme` CLI to run the specs in a disposable
Docker container.

Run all system specs for the theme:

```bash
discourse_theme rspec spec/system
```

Run a single spec file:

```bash
discourse_theme rspec spec/system/text_block_visibility_spec.rb
```

Run in headful mode (open Chrome so you can watch/debug):

```bash
discourse_theme rspec spec/system --headful
```

Notes:
- If this repository is a theme *component* (not a full theme) the system spec
uses `upload_theme_component` to install the component into the test
instance. The spec files live under `spec/system` and use Fabricate helpers
from Discourse core to create users/groups for tests.
- Tests run in Docker by default; see the `discourse_theme` documentation for
advanced usage, local Discourse integration, and CI setup:
https://github.com/discourse/discourse_theme
9 changes: 2 additions & 7 deletions javascripts/discourse/api-initializers/custom-components.gjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";
import { apiInitializer } from "discourse/lib/api";
import ButtonLink from "../components/button-link";
import TextBlock from "../components/text-block";

export default apiInitializer((api) => {
// loop through settings.buttons and render a button for each one
Expand All @@ -15,11 +14,7 @@ export default apiInitializer((api) => {
settings.custom_text_block.forEach((component) => {
api.renderInOutlet(
component.outlet,
<template>
<div class={{concatClass "custom-component" component.class}}>
{{htmlSafe component.text}}
</div>
</template>
<template><TextBlock @component={{component}} /></template>
);
});
});
4 changes: 2 additions & 2 deletions javascripts/discourse/components/button-link.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import concatClass from "discourse/helpers/concat-class";
export default class ButtonLink extends Component {
@service currentUser;

get showButtonLink() {
get shouldRender() {
let isGroupMember =
!!this.currentUser &&
this.args.button.groups.some((group) => {
Expand All @@ -27,7 +27,7 @@ export default class ButtonLink extends Component {
}

<template>
{{#if this.showButtonLink}}
{{#if this.shouldRender}}
<DButton
@icon={{this.button.icon}}
@translatedLabel={{this.button.text}}
Expand Down
40 changes: 40 additions & 0 deletions javascripts/discourse/components/text-block.gjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";

export default class TextBlock extends Component {
@service currentUser;

get shouldRender() {
const component = this.args?.component;
if (!component) {
return false;
}
const groups = component.groups ?? [];
const currentUser = this.currentUser;
if (!groups.length) {
// No groups configured: show unless explicit hide?
return component.group_action !== "show" ? true : false;
}
const userGroups = currentUser?.groups ?? [];
const isGroupMember =
!!currentUser && groups.some((g) => userGroups.some((ug) => ug.id === g));
return component.group_action === "show" ? isGroupMember : !isGroupMember;
}

get component() {
return this.args.component;
}

<template>
{{#if this.shouldRender}}
<div
class={{concatClass "custom-component" this.component.class}}
id={{this.component.id}}
>
{{htmlSafe this.component.text}}
</div>
{{/if}}
</template>
}
4 changes: 4 additions & 0 deletions settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions spec/support/theme_helpers.rb
Original file line number Diff line number Diff line change
@@ -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
99 changes: 99 additions & 0 deletions spec/system/button_visibility_spec.rb
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions spec/system/page_objects/components/base.rb
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions spec/system/page_objects/components/button_component.rb
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions spec/system/page_objects/components/text_block_component.rb
Original file line number Diff line number Diff line change
@@ -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
Loading