Skip to content

Commit 10f7efb

Browse files
committed
Implement ViewComponent for UI components
1 parent c45f784 commit 10f7efb

File tree

10 files changed

+248
-5
lines changed

10 files changed

+248
-5
lines changed

Gemfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,6 @@ end
6868
gem "devise", "~> 4.9"
6969
# Basic rate limiting and abuse protection
7070
gem "rack-attack"
71-
gem "solid_queue", "~> 1.2.1"
71+
72+
# Add ViewComponent for building reusable, testable & encapsulated view components
73+
gem "view_component", "~> 3.11"

Gemfile.lock

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ GEM
159159
net-smtp
160160
marcel (1.0.4)
161161
matrix (0.4.3)
162+
method_source (1.1.0)
162163
mini_mime (1.1.5)
163164
minitest (5.25.5)
164165
msgpack (1.8.0)
@@ -358,6 +359,10 @@ GEM
358359
unicode-emoji (4.0.4)
359360
uri (1.0.3)
360361
useragent (0.16.11)
362+
view_component (3.23.2)
363+
activesupport (>= 5.2.0, < 8.1)
364+
concurrent-ruby (~> 1)
365+
method_source (~> 1.0)
361366
warden (1.2.9)
362367
rack (>= 2.0.9)
363368
web-console (4.2.1)
@@ -409,6 +414,7 @@ DEPENDENCIES
409414
thruster
410415
turbo-rails
411416
tzinfo-data
417+
view_component (~> 3.11)
412418
web-console
413419

414420
BUNDLED WITH
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<% @flash.each do |type, message| %>
2+
<% next if message.blank? %>
3+
<div class="mb-3 rounded-md border px-4 py-2 text-sm <%= flash_class(type) %>">
4+
<%= message %>
5+
</div>
6+
<% end %>

app/components/flash_component.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class FlashComponent < ViewComponent::Base
2+
def initialize(flash:)
3+
@flash = flash
4+
end
5+
6+
def render?
7+
@flash.present? && @flash.any? { |_, message| message.present? }
8+
end
9+
10+
private
11+
12+
def flash_class(type)
13+
case type.to_s
14+
when "notice", "success"
15+
"border-green-200 bg-green-50 text-green-800"
16+
when "alert", "error"
17+
"border-red-200 bg-red-50 text-red-800"
18+
else
19+
"border-blue-200 bg-blue-50 text-blue-800"
20+
end
21+
end
22+
end
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<nav class="w-full py-3 px-6 bg-white border-b border-gray-200">
2+
<div class="flex justify-between items-center">
3+
<div class="flex items-center gap-4">
4+
<%# Main navigation links %>
5+
<%= link_to t("nav.home"), root_path, class: "text-sm font-medium text-gray-500 hover:text-gray-900 transition-colors" %>
6+
<%= link_to t("nav.about"), about_path, class: "text-sm font-medium text-gray-500 hover:text-gray-900 transition-colors" %>
7+
<%= link_to t("nav.new_entry"), new_entry_path, class: "text-sm font-medium text-gray-500 hover:text-gray-900 transition-colors" %>
8+
</div>
9+
10+
<div class="flex items-center gap-4">
11+
<%# Language dropdown %>
12+
<div class="relative">
13+
<details class="group">
14+
<summary class="list-none inline-flex items-center gap-2 px-3 py-2 text-xs font-medium text-gray-600 bg-gray-100 rounded-md cursor-pointer hover:bg-gray-200">
15+
<span><%= I18n.locale.to_s.upcase %></span>
16+
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
17+
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.23 8.27a.75.75 0 01.02-1.06z" clip-rule="evenodd"/>
18+
</svg>
19+
</summary>
20+
<div class="absolute right-0 mt-2 w-44 bg-white border border-gray-200 rounded-md shadow-lg py-1 z-20">
21+
<%= link_to "English (EN)", locale_url(:en), class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
22+
<%= link_to "Español (ES)", locale_url(:es), class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
23+
<%= link_to "Français (FR)", locale_url(:fr), class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
24+
<%= link_to "Português (PT)", locale_url(:pt), class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
25+
<%= link_to "Bahasa Indonesia (ID)", locale_url(:id), class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
26+
<%= link_to "中文 (ZH)", locale_url(:zh), class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
27+
<%= link_to "日本語 (JA)", locale_url(:ja), class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
28+
</div>
29+
</details>
30+
</div>
31+
32+
<% if user_signed_in? %>
33+
<%# Authenticated user menu %>
34+
<%= link_to t("nav.dashboard"), entries_path, class: "text-sm font-medium text-gray-500 hover:text-gray-900 transition-colors" %>
35+
36+
<div class="relative inline-block text-left">
37+
<div class="group">
38+
<button type="button" class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 transition-colors">
39+
<span><%= t("nav.menu") %></span>
40+
<svg class="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
41+
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.23 8.27a.75.75 0 01.02-1.06z" clip-rule="evenodd"/>
42+
</svg>
43+
</button>
44+
45+
<div class="absolute right-0 w-56 mt-2 origin-top-right bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 ease-in-out z-10 focus:outline-none">
46+
<div class="px-4 py-3">
47+
<p class="text-sm text-gray-500"><%= t("nav.signed_in_as") %></p>
48+
<p class="text-sm font-medium text-gray-900 truncate"><%= @current_user.email %></p>
49+
</div>
50+
<div class="py-1 border-t border-gray-100">
51+
<%= link_to t("nav.profile"), edit_user_registration_path, class: "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
52+
<%= link_to t("nav.sign_out"), destroy_user_session_path, data: { turbo_method: :delete }, class: "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
53+
</div>
54+
</div>
55+
</div>
56+
</div>
57+
<% else %>
58+
<%# Guest menu %>
59+
<%= link_to t("nav.sign_in"), new_user_session_path, class: "text-sm font-medium text-gray-500 hover:text-gray-900 transition-colors" %>
60+
<%= link_to t("nav.sign_up"), new_user_registration_path, class: "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 transition-colors" %>
61+
<% end %>
62+
</div>
63+
</div>
64+
</nav>

app/components/navbar_component.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
class NavbarComponent < ViewComponent::Base
2+
include ActionView::Helpers::TranslationHelper
3+
4+
def initialize(current_user: nil)
5+
@current_user = current_user
6+
end
7+
8+
def user_signed_in?
9+
@current_user.present?
10+
end
11+
12+
# Helper method to safely generate URLs in tests and production
13+
def locale_url(locale)
14+
if helpers.respond_to?(:url_for)
15+
begin
16+
helpers.url_for(locale: locale)
17+
rescue ActionController::UrlGenerationError
18+
# Fallback for tests
19+
"#"
20+
end
21+
else
22+
"#"
23+
end
24+
end
25+
end

app/views/layouts/application.html.erb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@
2525
<span class="font-semibold"><%= t("app.name") %></span>
2626
<% end %>
2727

28-
<%= render "shared/navbar" %>
28+
<%= render NavbarComponent.new(current_user: current_user) %>
2929
</div>
3030
</header>
3131

3232
<main class="container mx-auto px-4 py-6 flex-grow">
3333
<div class="max-w-5xl mx-auto">
34-
<%= render "shared/flash" %>
34+
<%= render FlashComponent.new(flash: flash) %>
3535
<%= yield %>
3636
</div>
3737
</main>

app/views/layouts/devise.html.erb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@
2727
<span class="font-semibold"><%= t("app.name") %></span>
2828
<% end %>
2929

30-
<%= render "shared/navbar" %>
30+
<%= render NavbarComponent.new(current_user: current_user) %>
3131
</div>
3232
</header>
3333

3434
<main class="container mx-auto px-4 py-12 flex-grow">
3535
<div class="max-w-md mx-auto">
36-
<%= render "shared/flash" %>
36+
<%= render FlashComponent.new(flash: flash) %>
3737
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-6">
3838
<%= yield %>
3939
</div>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
require "test_helper"
2+
3+
class FlashComponentTest < ViewComponent::TestCase
4+
def test_component_renders_nothing_when_flash_is_empty
5+
component = FlashComponent.new(flash: {})
6+
assert_equal false, component.render?
7+
end
8+
9+
def test_component_renders_notice_flash
10+
flash = ActionDispatch::Flash::FlashHash.new
11+
flash[:notice] = "Test notice message"
12+
13+
component = FlashComponent.new(flash: flash)
14+
render_inline(component)
15+
16+
assert_selector ".border-green-200.bg-green-50.text-green-800", text: "Test notice message"
17+
end
18+
19+
def test_component_renders_alert_flash
20+
flash = ActionDispatch::Flash::FlashHash.new
21+
flash[:alert] = "Test alert message"
22+
23+
component = FlashComponent.new(flash: flash)
24+
render_inline(component)
25+
26+
assert_selector ".border-red-200.bg-red-50.text-red-800", text: "Test alert message"
27+
end
28+
29+
def test_component_renders_other_flash_types
30+
flash = ActionDispatch::Flash::FlashHash.new
31+
flash[:info] = "Test info message"
32+
33+
component = FlashComponent.new(flash: flash)
34+
render_inline(component)
35+
36+
assert_selector ".border-blue-200.bg-blue-50.text-blue-800", text: "Test info message"
37+
end
38+
39+
def test_component_skips_blank_messages
40+
flash = ActionDispatch::Flash::FlashHash.new
41+
flash[:notice] = ""
42+
flash[:alert] = "This should show"
43+
44+
component = FlashComponent.new(flash: flash)
45+
render_inline(component)
46+
47+
assert_no_selector ".border-green-200.bg-green-50.text-green-800"
48+
assert_selector ".border-red-200.bg-red-50.text-red-800", text: "This should show"
49+
end
50+
end
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
require "test_helper"
2+
3+
class NavbarComponentTest < ViewComponent::TestCase
4+
include ActionView::Helpers::TranslationHelper
5+
6+
# Mock the component's helpers method for testing
7+
class TestNavbarComponent < NavbarComponent
8+
def helpers
9+
MockHelpers.new
10+
end
11+
end
12+
13+
# Simple mock for helpers
14+
class MockHelpers
15+
def url_for(options)
16+
"#"
17+
end
18+
19+
def respond_to?(method_name)
20+
method_name == :url_for || super
21+
end
22+
end
23+
24+
setup do
25+
# No setup needed
26+
end
27+
28+
def test_renders_guest_menu_when_not_signed_in
29+
component = TestNavbarComponent.new(current_user: nil)
30+
render_inline(component)
31+
32+
# Guest menu links should be present
33+
assert_link t("nav.sign_in")
34+
assert_link t("nav.sign_up")
35+
36+
# User menu elements should not be present
37+
assert_no_link t("nav.dashboard")
38+
assert_no_text t("nav.signed_in_as")
39+
end
40+
41+
def test_renders_user_menu_when_signed_in
42+
user = User.new(email: "[email protected]")
43+
component = TestNavbarComponent.new(current_user: user)
44+
render_inline(component)
45+
46+
# User menu elements should be present
47+
assert_link t("nav.dashboard")
48+
assert_link t("nav.profile")
49+
assert_link t("nav.sign_out")
50+
51+
# Should show user email
52+
assert_text "[email protected]"
53+
54+
# Guest elements should not be present
55+
assert_no_link t("nav.sign_up")
56+
end
57+
58+
def test_language_dropdown_is_always_present
59+
# Test with user not signed in
60+
component = TestNavbarComponent.new(current_user: nil)
61+
render_inline(component)
62+
63+
# Should show current locale and language options
64+
assert_text I18n.locale.to_s.upcase
65+
assert_text "English (EN)"
66+
assert_text "Español (ES)"
67+
end
68+
end

0 commit comments

Comments
 (0)