Skip to content

Commit bc40a1e

Browse files
committed
Add support for localized attachments in Mobility
- Introduced a new backend for handling localized attachments using ActiveStorage. - Created a DSL for applying localized attachment accessors to models. - Added integration tests for translatable attachments functionality. - Implemented model specs to ensure correct behavior of localized accessors and writers. - Updated database schema to include locale for ActiveStorage attachments. - Enhanced existing helper methods for table creation and dropping in tests.
1 parent db15e3c commit bc40a1e

19 files changed

+1247
-18
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<%# Render a tabbed file input for each locale. Expects locals: f (form builder), attribute (symbol), model %>
2+
<div data-controller="translated-tabs">
3+
<ul class="nav nav-tabs" role="tablist">
4+
<% (Mobility.available_locales rescue I18n.available_locales).each_with_index do |locale, i| %>
5+
<li class="nav-item" role="presentation">
6+
<button class="nav-link <%= 'active' if i == 0 %>" id="<%= "#{attribute}-#{locale}" %>-tab" data-bs-toggle="tab" data-bs-target="#<%= "#{attribute}-#{locale}" %>" type="button" role="tab">
7+
<%= locale %>
8+
</button>
9+
</li>
10+
<% end %>
11+
</ul>
12+
<div class="tab-content">
13+
<% (Mobility.available_locales rescue I18n.available_locales).each_with_index do |locale, i| %>
14+
<div class="tab-pane <%= 'active' if i == 0 %>" id="<%= "#{attribute}-#{locale}" %>" role="tabpanel">
15+
<div class="mb-2">
16+
<% existing = model.send("#{attribute}_#{locale}") rescue nil %>
17+
<% if existing && existing.respond_to?(:blob) && existing.blob.present? %>
18+
<% if existing.blob.image? %>
19+
<%= image_tag url_for(existing.blob.variant(resize: "300x200")) %>
20+
<% else %>
21+
<%= link_to existing.blob.filename.to_s, url_for(existing.blob) %>
22+
<% end %>
23+
<% end %>
24+
</div>
25+
<div class="form-group">
26+
<%= f.file_field "#{attribute}_#{locale}", class: 'form-control' %>
27+
</div>
28+
</div>
29+
<% end %>
30+
</div>
31+
</div>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
# Extend ActiveStorage::Attachment with a locale attribute helpers
4+
Rails.application.config.to_prepare do
5+
ActiveSupport.on_load(:active_storage_attachment) do
6+
# ensure presence of locale is validated at model level as well
7+
unless method_defined?(:locale)
8+
# The migration will add locale column. Guard so this file can be loaded pre-migration.
9+
define_method(:locale) { read_attribute(:locale) if respond_to?(:read_attribute) }
10+
end
11+
12+
include Module.new do
13+
def self.included(base)
14+
base.class_eval do
15+
validates :locale, presence: true
16+
17+
scope :for_locale, ->(locale) { where(locale: locale.to_s) }
18+
end
19+
end
20+
end
21+
end
22+
end

config/initializers/mobility.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,32 @@
107107
# locale_accessors [:en, :ja]
108108
end
109109
end
110+
111+
# Ensure localized attachments backend is loaded and registered early
112+
begin
113+
require 'mobility/backends/attachments/backend'
114+
rescue LoadError => e
115+
Rails.logger.debug "Could not require mobility attachments backend: #{e.message}"
116+
end
117+
118+
# Register backend symbol if Mobility exposes the API. Some test bootstraps
119+
# may not have Mobility fully loaded yet; guard registration to avoid raising
120+
# during initializer phases where Mobility isn't loaded.
121+
begin
122+
if Mobility.respond_to?(:register_backend)
123+
Mobility.register_backend(:attachments, Mobility::Backends::Attachments)
124+
else
125+
Rails.logger.debug 'Mobility does not expose register_backend; skipping attachments backend registration'
126+
end
127+
rescue StandardError => e
128+
Rails.logger.debug "Error registering mobility attachments backend: #{e.message}"
129+
end
130+
begin
131+
require 'mobility/dsl/attachments'
132+
# Make the DSL available as an extension so models can `extend Mobility::DSL::Attachments`
133+
ActiveSupport.on_load(:active_record) do
134+
ActiveRecord::Base.extend Mobility::DSL::Attachments
135+
end
136+
rescue LoadError => e
137+
Rails.logger.debug "Could not load Mobility attachments DSL: #{e.message}"
138+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
# Adds a locale column to Active Storage attachments to support per-locale attachments.
4+
# This migration is intentionally explicit and kept readable rather than split into
5+
# many tiny helper methods.
6+
class AddLocaleToActiveStorageAttachments < ActiveRecord::Migration[7.1]
7+
# rubocop:disable Metrics/MethodLength
8+
def up
9+
# Step 1: add nullable column so deploy can be staged
10+
add_column :active_storage_attachments, :locale, :string
11+
12+
# Step 2: backfill existing rows to default locale in one SQL update
13+
say_with_time("Backfilling active_storage_attachments.locale to default locale") do
14+
default = I18n.default_locale.to_s
15+
ActiveRecord::Base.connection.execute(<<~SQL)
16+
UPDATE active_storage_attachments
17+
SET locale = #{ActiveRecord::Base.connection.quote(default)}
18+
WHERE locale IS NULL
19+
SQL
20+
end
21+
22+
# Step 3: make column not null
23+
change_column_null :active_storage_attachments, :locale, false, I18n.default_locale.to_s
24+
25+
# Step 4: add unique index to enforce one attachment per record/name/locale
26+
unless index_exists?(:active_storage_attachments,
27+
%i[record_type record_id name locale],
28+
name: :index_active_storage_attachments_on_record_and_name_and_locale)
29+
add_index :active_storage_attachments,
30+
%i[record_type record_id name locale],
31+
unique: true,
32+
name: :index_active_storage_attachments_on_record_and_name_and_locale
33+
end
34+
end
35+
36+
# rubocop:enable Metrics/MethodLength
37+
38+
def down
39+
if index_exists?(:active_storage_attachments, %i[record_type record_id name locale],
40+
name: :index_active_storage_attachments_on_record_and_name_and_locale)
41+
remove_index :active_storage_attachments, name: :index_active_storage_attachments_on_record_and_name_and_locale
42+
end
43+
remove_column :active_storage_attachments, :locale if column_exists?(:active_storage_attachments, :locale)
44+
end
45+
end
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
## Translatable Attachments (developer guide)
2+
3+
Overview
4+
--------
5+
6+
This guide documents the Mobility-style solution for per-locale Active Storage
7+
attachments used in the project. Implementation highlights:
8+
9+
- Adds `locale:string` to `active_storage_attachments` and backfills to the
10+
default locale.
11+
- Provides a Mobility backend in `lib/mobility/backends/attachments/backend.rb`
12+
which defines per-attribute, per-locale associations and accessors.
13+
- Includes a small DSL at `lib/mobility/dsl/attachments.rb` to apply the
14+
backend setup immediately at class-definition time (`translates_attached`).
15+
16+
Where to look
17+
-------------
18+
19+
- Backend implementation: `lib/mobility/backends/attachments/backend.rb`
20+
- DSL for early use: `lib/mobility/dsl/attachments.rb` (`Mobility::DSL::Attachments`)
21+
- Initializer wiring: `config/initializers/mobility.rb` (requires + registers
22+
the backend and loads the DSL)
23+
- Migration: `db/migrate/20250829000000_add_locale_to_active_storage_attachments.rb`
24+
- Example form partial: `app/views/better_together/shared/_translated_file_field.html.erb`
25+
26+
Model usage
27+
-----------
28+
29+
Canonical (preferred when backend registration runs early):
30+
31+
class Page < ApplicationRecord
32+
translates :hero_image, backend: :attachments, content_type: [/image/], presence: true
33+
end
34+
35+
Early-definition (tests, engines):
36+
37+
class Page < ApplicationRecord
38+
extend Mobility::DSL::Attachments
39+
translates_attached :hero_image, content_type: [/image/], presence: true
40+
end
41+
42+
The backend will generate accessors like `hero_image_en`, `hero_image_en=`,
43+
`hero_image_en?`, and `hero_image_en_url` for each configured locale.
44+
45+
Migration notes
46+
---------------
47+
48+
Use a staged migration: add a nullable `locale` column, backfill existing
49+
attachment rows to the default locale, then set NOT NULL and add a unique
50+
index on `[:record_type, :record_id, :name, :locale]`.
51+
52+
Testing
53+
-------
54+
55+
- Unit: `spec/lib/mobility_attachments_backend_spec.rb` demonstrates the
56+
generation of accessors. The test uses `translates_attached` to guarantee
57+
generation timing in the test environment.
58+
- Integration: Add feature specs to exercise form uploads using the
59+
`_translated_file_field` partial and verify controller param handling.
60+
61+
Developer tips
62+
--------------
63+
64+
- Prefer the canonical `translates` API when possible. The DSL is intended
65+
for boot-order-sensitive contexts.
66+
- Validate `content_type` and `presence` server-side via the backend options.
67+
- When deploying, ensure migrations are run and backfills complete before
68+
enabling any model to rely on the `locale` column being present and NOT NULL.
69+
70+
## Process Flow Diagram
71+
72+
```mermaid
73+
%% Translatable Attachments flow
74+
flowchart LR
75+
subgraph DB[Database]
76+
A[active_storage_attachments table]
77+
end
78+
79+
M[Migration adds `locale` column & backfill] --> A
80+
81+
Init[Mobility initializer]
82+
Init -->|require backend| B[Attachments backend (lib/mobility/backends/...)]
83+
Init -->|register backend| Mobility[Mobility.register_backend(:attachments)]
84+
85+
B -->|provides| Apply[AttachmentsBackend.apply_to / setup]
86+
87+
ModelCanonical[Model: `translates :hero_image, backend: :attachments`]
88+
ModelDSL[Model: `extend Mobility::DSL::Attachments`\n`translates_attached :hero_image`]
89+
90+
ModelCanonical --> Apply
91+
ModelDSL --> Apply
92+
93+
Apply -->|defines associations| Assoc[has_many :hero_image_attachments_all\nhas_one :hero_image_attachment]
94+
Apply -->|defines accessors| Accessors[hero_image_en, hero_image_en=, hero_image_en?, hero_image_en_url]
95+
96+
View[Form partial: translated file field tabs]
97+
View -->|uploads per-locale| Controller
98+
99+
Controller[Controller] -->|permits locale params or maps| Model
100+
Model -->|writer methods| ActiveStorage[Create/modify ActiveStorage::Attachment rows]
101+
ActiveStorage --> A
102+
103+
Serve[URL helper / rails_blob_url] -->|serves blob| UserBrowser[User's browser]
104+
105+
Accessors -->|getter fallback| Fallback[Default-locale fallback if enabled]
106+
Fallback --> ActiveStorage
107+
108+
classDef infra fill:#f8f9fa,stroke:#333,stroke-width:1px;
109+
class DB,Init,B,Apply,Assoc,Accessors,View,Controller,ActiveStorage,Serve,Fallback infra;
110+
```
111+
112+
Diagram files:
113+
114+
- Mermaid source: `docs/diagrams/source/translatable_attachments_flow.mmd` - editable source
115+
- (Optional) PNG/SVG exports: `docs/diagrams/exports/...` - add via `bin/render_diagrams` if you generate exports
116+
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
%% Translatable Attachments - Entity Relationship
2+
%% Generated: 2025-08-29
3+
4+
classDiagram
5+
direction TB
6+
7+
class ActiveStorage::Blob {
8+
+uuid id
9+
+string filename
10+
+string content_type
11+
+integer byte_size
12+
+json metadata
13+
}
14+
15+
class ActiveStorage::Attachment {
16+
+uuid id
17+
+string name
18+
+string record_type
19+
+uuid record_id
20+
+uuid blob_id
21+
+string locale
22+
+datetime created_at
23+
}
24+
25+
class Page {
26+
+uuid id
27+
+string title
28+
}
29+
30+
Page "1" o-- "*" ActiveStorage::Attachment : has_many
31+
ActiveStorage::Attachment "*" --> "1" ActiveStorage::Blob : belongs_to
32+
33+
%% locale uniqueness
34+
note right of ActiveStorage::Attachment: Unique index on
35+
note right of ActiveStorage::Attachment: (record_type, record_id, name, locale)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
%% Translatable Attachments flow
2+
flowchart LR
3+
subgraph DB[Database]
4+
A[active_storage_attachments table]
5+
end
6+
7+
M[Migration adds `locale` column & backfill] --> A
8+
9+
Init[Mobility initializer]
10+
Init -->|require backend| B[Attachments backend (lib/mobility/backends/...)]
11+
Init -->|register backend| Mobility[Mobility.register_backend(:attachments)]
12+
13+
B -->|provides| Apply[AttachmentsBackend.apply_to / setup]
14+
15+
ModelCanonical[Model: `translates :hero_image, backend: :attachments`]
16+
ModelDSL[Model: `extend Mobility::DSL::Attachments`\n`translates_attached :hero_image`]
17+
18+
ModelCanonical --> Apply
19+
ModelDSL --> Apply
20+
21+
Apply -->|defines associations| Assoc[has_many :hero_image_attachments_all\nhas_one :hero_image_attachment]
22+
Apply -->|defines accessors| Accessors[hero_image_en, hero_image_en=, hero_image_en?, hero_image_en_url]
23+
24+
View[Form partial: translated file field tabs]
25+
View -->|uploads per-locale| Controller
26+
27+
Controller[Controller] -->|permits locale params or maps| Model
28+
Model -->|writer methods| ActiveStorage[Create/modify ActiveStorage::Attachment rows]
29+
ActiveStorage --> A
30+
31+
Serve[URL helper / rails_blob_url] -->|serves blob| UserBrowser[User's browser]
32+
33+
Accessors -->|getter fallback| Fallback[Default-locale fallback if enabled]
34+
Fallback --> ActiveStorage
35+
36+
classDef infra fill:#f8f9fa,stroke:#333,stroke-width:1px;
37+
class DB,Init,B,Apply,Assoc,Accessors,View,Controller,ActiveStorage,Serve,Fallback infra;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
## Translatable Attachments — Organizer Guide
2+
3+
Purpose
4+
-------
5+
6+
Organizers can upload images or files that are specific to a language or
7+
locale. The translatable attachments feature stores one attachment per locale
8+
so you can present localized media to your users.
9+
10+
How to use
11+
----------
12+
13+
When editing content in the admin interface, you'll see a tabbed file upload
14+
field labelled by language (for example, "English", "Français"). Upload a
15+
file in each tab to provide localized media.
16+
17+
Behavior
18+
--------
19+
20+
- The site will use the file for the requested locale if present.
21+
- If a file for the requested locale is not present, the system may fall back
22+
to the default language's file (this is configurable by developers).
23+
- When you remove a file from a locale, that locale-specific attachment will
24+
be deleted; other locales are unaffected.
25+
26+
Best practices
27+
--------------
28+
29+
- Provide translations for alt text and captions to match the localized
30+
media — the attachment itself is just the binary file.
31+
- Keep file sizes optimized for the web; large images slow down page loads.
32+
33+
Troubleshooting
34+
---------------
35+
36+
- If you don't see the language tabs, ensure you have appropriate
37+
permissions and that your organization has multiple locales enabled.
38+
- If uploads fail, check file type restrictions (some admins forbid certain
39+
file types) and contact the technical team.

0 commit comments

Comments
 (0)