Skip to content

Commit b2c8286

Browse files
committed
feat(translations): implement translation management index and statistics
1 parent b48d63e commit b2c8286

File tree

6 files changed

+362
-0
lines changed

6 files changed

+362
-0
lines changed

app/controllers/better_together/translations_controller.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,57 @@
22

33
module BetterTogether
44
class TranslationsController < ApplicationController # rubocop:todo Style/Documentation
5+
def index
6+
# Get the locale filter from params, default to 'all'
7+
@locale_filter = params[:locale_filter] || 'all'
8+
@available_locales = I18n.available_locales.map(&:to_s)
9+
10+
# Base query for translated model types
11+
base_query = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation
12+
.order(:translatable_type)
13+
14+
# Apply locale filter if specified
15+
if @locale_filter != 'all' && @available_locales.include?(@locale_filter)
16+
base_query = base_query.where(locale: @locale_filter)
17+
end
18+
19+
@translated_model_types = base_query.pluck(:translatable_type).uniq
20+
21+
# Calculate translation statistics per locale and model type
22+
@translation_stats = calculate_translation_stats if @translated_model_types.any?
23+
end
24+
25+
private
26+
27+
def calculate_translation_stats
28+
stats = {}
29+
30+
@translated_model_types.each do |model_type|
31+
stats[model_type] = {}
32+
33+
@available_locales.each do |locale|
34+
# Count total records and translated records for this model and locale
35+
total_records = begin
36+
model_type.constantize.count
37+
rescue StandardError
38+
0
39+
end
40+
translated_count = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation
41+
.where(translatable_type: model_type, locale: locale)
42+
.distinct(:translatable_id)
43+
.count
44+
45+
stats[model_type][locale] = {
46+
total: total_records,
47+
translated: translated_count,
48+
percentage: total_records > 0 ? ((translated_count.to_f / total_records) * 100).round(1) : 0
49+
}
50+
end
51+
end
52+
53+
stats
54+
end
55+
556
def translate
657
content = params[:content]
758
source_locale = params[:source_locale]
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
<% content_for :page_title, t('.title') %>
2+
3+
<div class="container-fluid" data-controller="better-together--tabs better-together--translation">
4+
<div class="row">
5+
<div class="col-12">
6+
<h1 class="h2 mb-4">
7+
<i class="fa-solid fa-language me-2" aria-hidden="true"></i>
8+
<%= t('.title') %>
9+
</h1>
10+
11+
<% if @translated_model_types.any? %>
12+
<%
13+
# Get current locale filter from params
14+
selected_locale = params[:locale_filter] || 'all'
15+
available_locales = I18n.available_locales.map(&:to_s)
16+
17+
# Group model types by namespace
18+
grouped_models = @translated_model_types.group_by do |model_type|
19+
parts = model_type.split('::')
20+
parts.length > 1 ? parts[0..-2].join('::') : 'Base'
21+
end
22+
23+
# Sort groups with 'BetterTogether' first, then 'Base', then alphabetically
24+
sorted_groups = grouped_models.sort_by do |namespace, _|
25+
case namespace
26+
when 'BetterTogether' then '0'
27+
when 'Base' then 'z'
28+
else namespace
29+
end
30+
end
31+
%>
32+
33+
<!-- Locale Filter Tabs -->
34+
<div class="mb-4">
35+
<h3 class="h5 mb-3">
36+
<i class="fa-solid fa-filter me-2" aria-hidden="true"></i>
37+
Filter by Locale
38+
</h3>
39+
<ul class="nav nav-pills" id="localeFilterTabs" role="tablist">
40+
<li class="nav-item" role="presentation">
41+
<%= link_to better_together.translations_path(locale_filter: 'all'),
42+
class: "nav-link #{'active' if selected_locale == 'all'}",
43+
id: "all-locales-tab",
44+
role: "tab",
45+
'aria-selected': selected_locale == 'all',
46+
data: {
47+
'better-together--translation-target': 'tab',
48+
locale: 'all'
49+
} do %>
50+
<i class="fa-solid fa-globe me-2" aria-hidden="true"></i>
51+
All Locales
52+
<span class="badge bg-secondary ms-2"><%= @translated_model_types.count %> models</span>
53+
<% end %>
54+
</li>
55+
<% available_locales.each do |locale| %>
56+
<%
57+
# Calculate total translation percentage for this locale across all models
58+
if @translation_stats&.any?
59+
total_records = @translation_stats.values.sum { |model_stats| model_stats[locale]&.dig(:total) || 0 }
60+
total_translated = @translation_stats.values.sum { |model_stats| model_stats[locale]&.dig(:translated) || 0 }
61+
locale_percentage = total_records > 0 ? ((total_translated.to_f / total_records) * 100).round(1) : 0
62+
else
63+
locale_percentage = 0
64+
total_translated = 0
65+
end
66+
%>
67+
<li class="nav-item" role="presentation">
68+
<%= link_to better_together.translations_path(locale_filter: locale),
69+
class: "nav-link #{'active' if selected_locale == locale}",
70+
id: "#{locale}-locale-tab",
71+
role: "tab",
72+
'aria-selected': selected_locale == locale,
73+
data: {
74+
'better-together--translation-target': 'tab',
75+
locale: locale
76+
} do %>
77+
<i class="fa-solid fa-language me-2" aria-hidden="true"></i>
78+
<%= t("locales.#{locale}") %>
79+
<span class="badge bg-<%= locale_percentage >= 80 ? 'success' : locale_percentage >= 50 ? 'warning' : 'danger' %> ms-2">
80+
<%= locale_percentage %>% (<%= total_translated %>)
81+
</span>
82+
<% end %>
83+
</li>
84+
<% end %>
85+
</ul>
86+
</div>
87+
88+
<% if selected_locale != 'all' %>
89+
<div class="alert alert-info mb-4" role="alert">
90+
<i class="fa-solid fa-info-circle me-2" aria-hidden="true"></i>
91+
Showing translation data filtered for: <strong><%= t("locales.#{selected_locale}") %></strong>
92+
<%= link_to "Show all locales", better_together.translations_path, class: "btn btn-sm btn-outline-primary ms-2" %>
93+
</div>
94+
<% end %>
95+
96+
<% sorted_groups.each_with_index do |(namespace, models), group_index| %>
97+
<div class="mb-4">
98+
<h2 class="h4 mb-3">
99+
<i class="fa-solid fa-folder me-2" aria-hidden="true"></i>
100+
<%= namespace == 'Base' ? 'Core Models' : namespace.humanize %>
101+
<span class="badge bg-secondary ms-2"><%= models.count %></span>
102+
</h2>
103+
104+
<!-- Navigation Tabs for this namespace -->
105+
<ul class="nav nav-tabs" id="translationsTab<%= group_index %>" role="tablist">
106+
<% models.each_with_index do |model_type, model_index| %>
107+
<%
108+
model_class = model_type.constantize
109+
tab_id = "#{model_type.downcase.gsub('::', '-')}-tab"
110+
panel_id = "##{model_type.downcase.gsub('::', '-')}-panel"
111+
is_active = group_index == 0 && model_index == 0
112+
113+
# Extract the class name without namespace for display
114+
class_name = model_type.split('::').last
115+
%>
116+
<li class="nav-item" role="presentation">
117+
<button class="nav-link<%= ' active' if is_active %>"
118+
id="<%= tab_id %>"
119+
data-bs-toggle="tab"
120+
data-bs-target="<%= panel_id %>"
121+
href="<%= panel_id %>"
122+
type="button"
123+
role="tab"
124+
aria-controls="<%= panel_id.sub('#', '') %>"
125+
aria-selected="<%= is_active %>"
126+
data-better-together--tabs-target="tab">
127+
<i class="fa-solid fa-file-text me-2" aria-hidden="true"></i>
128+
<%= class_name.humanize %>
129+
<% if namespace != 'Base' && namespace != 'BetterTogether' %>
130+
<small class="text-muted ms-1">(<%= namespace.split('::').last %>)</small>
131+
<% end %>
132+
</button>
133+
</li>
134+
<% end %>
135+
</ul>
136+
137+
<!-- Tab Content for this namespace -->
138+
<div class="tab-content mt-3 mb-4" id="translationsTabContent<%= group_index %>">
139+
<% models.each_with_index do |model_type, model_index| %>
140+
<%
141+
model_class = model_type.constantize
142+
panel_id = "#{model_type.downcase.gsub('::', '-')}-panel"
143+
tab_id = "#{model_type.downcase.gsub('::', '-')}-tab"
144+
is_active = group_index == 0 && model_index == 0
145+
146+
class_name = model_type.split('::').last
147+
%>
148+
<div class="tab-pane fade<%= ' show active' if is_active %>"
149+
id="<%= panel_id %>"
150+
role="tabpanel"
151+
aria-labelledby="<%= tab_id %>"
152+
tabindex="0">
153+
<div class="card">
154+
<div class="card-header d-flex justify-content-between align-items-center">
155+
<h3 class="card-title h5 mb-0">
156+
<i class="fa-solid fa-translate me-2" aria-hidden="true"></i>
157+
<%= model_class.model_name.human %>
158+
<small class="text-muted ms-2">(<%= model_type %>)</small>
159+
</h3>
160+
<% if selected_locale != 'all' %>
161+
<span class="badge bg-info">
162+
<i class="fa-solid fa-filter me-1" aria-hidden="true"></i>
163+
<%= t("locales.#{selected_locale}") %> Only
164+
</span>
165+
<% end %>
166+
</div>
167+
<div class="card-body">
168+
<%
169+
translated_attributes = model_class.respond_to?(:mobility_attributes) ? model_class.mobility_attributes : []
170+
%>
171+
172+
<% if translated_attributes.any? %>
173+
<div class="mb-4">
174+
<h6 class="text-muted mb-3">
175+
<i class="fa-solid fa-table me-2" aria-hidden="true"></i>
176+
Translation Values for <%= model_class.model_name.human %>
177+
</h6>
178+
179+
<div class="table-responsive">
180+
<table class="table table-striped table-hover">
181+
<thead class="table-dark">
182+
<tr>
183+
<th scope="col" class="text-nowrap">
184+
<i class="fa-solid fa-key me-2" aria-hidden="true"></i>
185+
ID
186+
</th>
187+
<th scope="col" class="text-nowrap">
188+
<i class="fa-solid fa-tag me-2" aria-hidden="true"></i>
189+
Identifier
190+
</th>
191+
<% translated_attributes.each do |attribute| %>
192+
<th scope="col" class="text-nowrap">
193+
<i class="fa-solid fa-language me-2" aria-hidden="true"></i>
194+
<%= attribute.to_s.humanize %>
195+
</th>
196+
<% end %>
197+
<th scope="col" class="text-nowrap">
198+
<i class="fa-solid fa-cog me-2" aria-hidden="true"></i>
199+
Actions
200+
</th>
201+
</tr>
202+
</thead>
203+
<tbody>
204+
<tr>
205+
<td colspan="<%= translated_attributes.count + 3 %>" class="text-center text-muted py-4">
206+
<i class="fa-solid fa-info-circle me-2" aria-hidden="true"></i>
207+
No <%= model_class.model_name.human.downcase %> records to display.
208+
Translation data will appear here when available.
209+
</td>
210+
</tr>
211+
</tbody>
212+
</table>
213+
</div>
214+
</div>
215+
216+
<div class="row">
217+
<div class="col-md-6">
218+
<h6 class="text-muted">Translated Attributes:</h6>
219+
<ul class="list-group list-group-flush">
220+
<% translated_attributes.each do |attribute| %>
221+
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
222+
<span>
223+
<i class="fa-solid fa-language me-2 text-primary" aria-hidden="true"></i>
224+
<code><%= attribute %></code>
225+
</span>
226+
<span class="badge bg-secondary rounded-pill">
227+
<%= attribute.to_s.humanize.downcase %>
228+
</span>
229+
</li>
230+
<% end %>
231+
</ul>
232+
</div>
233+
234+
<div class="col-md-6">
235+
<h6 class="text-muted">Model Information:</h6>
236+
<ul class="list-unstyled small text-muted">
237+
<li><strong>Full Class:</strong> <code><%= model_type %></code></li>
238+
<li><strong>Namespace:</strong> <code><%= namespace %></code></li>
239+
<li><strong>Model Name:</strong> <code><%= class_name %></code></li>
240+
<li><strong>Attributes Count:</strong> <%= translated_attributes.count %></li>
241+
</ul>
242+
243+
<div class="mt-3">
244+
<small class="text-muted">
245+
<i class="fa-solid fa-info-circle me-1" aria-hidden="true"></i>
246+
These attributes support multiple language translations through the Mobility gem.
247+
</small>
248+
</div>
249+
</div>
250+
</div>
251+
<% else %>
252+
<div class="alert alert-warning" role="alert">
253+
<i class="fa-solid fa-exclamation-triangle me-2" aria-hidden="true"></i>
254+
No translated attributes found for this model.
255+
</div>
256+
257+
<div class="mt-3">
258+
<h6 class="text-muted">Model Information:</h6>
259+
<ul class="list-unstyled small text-muted">
260+
<li><strong>Full Class:</strong> <code><%= model_type %></code></li>
261+
<li><strong>Namespace:</strong> <code><%= namespace %></code></li>
262+
<li><strong>Model Name:</strong> <code><%= class_name %></code></li>
263+
</ul>
264+
</div>
265+
<% end %>
266+
</div>
267+
</div>
268+
</div>
269+
<% end %>
270+
</div>
271+
</div>
272+
<% end %>
273+
<% else %>
274+
<div class="alert alert-info" role="alert">
275+
<i class="fa-solid fa-info-circle me-2" aria-hidden="true"></i>
276+
<%= t('.no_translatable_content') %>
277+
</div>
278+
<% end %>
279+
</div>
280+
</div>
281+
</div>

config/locales/en.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1489,6 +1489,10 @@ en:
14891489
support: Support
14901490
terms_of_service: Terms of Service
14911491
title: Website Links
1492+
translations:
1493+
index:
1494+
title: Translation Management
1495+
no_translatable_content: No translatable content is available at this time
14921496
block: :activerecord.models.block
14931497
community:
14941498
create_failed: Create failed

config/locales/es.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1500,6 +1500,27 @@ es:
15001500
support: Soporte
15011501
terms_of_service: Términos de Servicio
15021502
title: Enlaces de Sitio Web
1503+
translations:
1504+
index:
1505+
title: Gestión de Traducciones
1506+
no_translatable_content: No hay contenido traducible disponible en este momento
1507+
translations:
1508+
index:
1509+
title: Gestión de Traducciones
1510+
translate_model: "Traducir %{model}"
1511+
source_content: Contenido Fuente
1512+
source_locale: Idioma Fuente
1513+
target_locale: Idioma Destino
1514+
translated_content: Contenido Traducido
1515+
content_to_translate: Contenido a Traducir
1516+
translation_result: Resultado de la Traducción
1517+
enter_content_placeholder: Ingrese el contenido a traducir...
1518+
translation_will_appear_here: La traducción aparecerá aquí...
1519+
content_help_text: "Ingrese el contenido %{model} que necesita ser traducido"
1520+
translation_help_text: El contenido traducido aparecerá aquí después del procesamiento
1521+
translate_button: Traducir
1522+
translating_status: Traduciendo...
1523+
no_translatable_content: No hay contenido traducible disponible en este momento
15031524
block: :activerecord.models.block
15041525
community:
15051526
create_failed: Creación fallida

config/locales/fr.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,6 +1508,10 @@ fr:
15081508
support: Support
15091509
terms_of_service: Conditions d'utilisation
15101510
title: Liens de site web
1511+
translations:
1512+
index:
1513+
title: Gestion des Traductions
1514+
no_translatable_content: Aucun contenu traduisible n'est disponible pour le moment
15111515
block: :activerecord.models.block
15121516
community:
15131517
create_failed: Échec de la création

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@
175175

176176
# Only logged-in users have access to the AI translation feature for now. Needs code adjustments, too.
177177
scope path: :translations do
178+
get '/', to: 'translations#index', as: :translations
178179
post 'translate', to: 'translations#translate', as: :ai_translate
179180
end
180181

0 commit comments

Comments
 (0)