Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 31 additions & 0 deletions app/views/better_together/shared/_translated_file_field.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<%# Render a tabbed file input for each locale. Expects locals: f (form builder), attribute (symbol), model %>
<div data-controller="translated-tabs">
<ul class="nav nav-tabs" role="tablist">
<% (Mobility.available_locales rescue I18n.available_locales).each_with_index do |locale, i| %>
<li class="nav-item" role="presentation">
<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">
<%= locale %>
</button>
</li>
<% end %>
</ul>
<div class="tab-content">
<% (Mobility.available_locales rescue I18n.available_locales).each_with_index do |locale, i| %>
<div class="tab-pane <%= 'active' if i == 0 %>" id="<%= "#{attribute}-#{locale}" %>" role="tabpanel">
<div class="mb-2">
<% existing = model.send("#{attribute}_#{locale}") rescue nil %>
<% if existing && existing.respond_to?(:blob) && existing.blob.present? %>
<% if existing.blob.image? %>
<%= image_tag url_for(existing.blob.variant(resize: "300x200")) %>
<% else %>
<%= link_to existing.blob.filename.to_s, url_for(existing.blob) %>
<% end %>
<% end %>
</div>
<div class="form-group">
<%= f.file_field "#{attribute}_#{locale}", class: 'form-control' %>
</div>
</div>
<% end %>
</div>
</div>
22 changes: 22 additions & 0 deletions config/initializers/active_storage_locales.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

# Extend ActiveStorage::Attachment with a locale attribute helpers
Rails.application.config.to_prepare do
ActiveSupport.on_load(:active_storage_attachment) do
# ensure presence of locale is validated at model level as well
unless method_defined?(:locale)
# The migration will add locale column. Guard so this file can be loaded pre-migration.
define_method(:locale) { read_attribute(:locale) if respond_to?(:read_attribute) }
end

include Module.new do
def self.included(base)
base.class_eval do
validates :locale, presence: true

scope :for_locale, ->(locale) { where(locale: locale.to_s) }
end
end
end
end
end
29 changes: 29 additions & 0 deletions config/initializers/mobility.rb
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,32 @@
# locale_accessors [:en, :ja]
end
end

# Ensure localized attachments backend is loaded and registered early
begin
require 'mobility/backends/attachments/backend'
rescue LoadError => e
Rails.logger.debug "Could not require mobility attachments backend: #{e.message}"
end

# Register backend symbol if Mobility exposes the API. Some test bootstraps
# may not have Mobility fully loaded yet; guard registration to avoid raising
# during initializer phases where Mobility isn't loaded.
begin
if Mobility.respond_to?(:register_backend)
Mobility.register_backend(:attachments, Mobility::Backends::Attachments)
else
Rails.logger.debug 'Mobility does not expose register_backend; skipping attachments backend registration'
end
rescue StandardError => e
Rails.logger.debug "Error registering mobility attachments backend: #{e.message}"
end
begin
require 'mobility/dsl/attachments'
# Make the DSL available as an extension so models can `extend Mobility::DSL::Attachments`
ActiveSupport.on_load(:active_record) do
ActiveRecord::Base.extend Mobility::DSL::Attachments
end
rescue LoadError => e
Rails.logger.debug "Could not load Mobility attachments DSL: #{e.message}"
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

# Adds a locale column to Active Storage attachments to support per-locale attachments.
# This migration is intentionally explicit and kept readable rather than split into
# many tiny helper methods.
class AddLocaleToActiveStorageAttachments < ActiveRecord::Migration[7.1]
# rubocop:disable Metrics/MethodLength
def up
# Step 1: add nullable column so deploy can be staged
add_column :active_storage_attachments, :locale, :string

# Step 2: backfill existing rows to default locale in one SQL update
say_with_time("Backfilling active_storage_attachments.locale to default locale") do
default = I18n.default_locale.to_s
ActiveRecord::Base.connection.execute(<<~SQL)
UPDATE active_storage_attachments
SET locale = #{ActiveRecord::Base.connection.quote(default)}
WHERE locale IS NULL
SQL
end

# Step 3: make column not null
change_column_null :active_storage_attachments, :locale, false, I18n.default_locale.to_s

# Step 4: add unique index to enforce one attachment per record/name/locale
unless index_exists?(:active_storage_attachments,
%i[record_type record_id name locale],
name: :index_active_storage_attachments_on_record_and_name_and_locale)
add_index :active_storage_attachments,
%i[record_type record_id name locale],
unique: true,
name: :index_active_storage_attachments_on_record_and_name_and_locale
end
end

# rubocop:enable Metrics/MethodLength

def down
if index_exists?(:active_storage_attachments, %i[record_type record_id name locale],
name: :index_active_storage_attachments_on_record_and_name_and_locale)
remove_index :active_storage_attachments, name: :index_active_storage_attachments_on_record_and_name_and_locale
end
remove_column :active_storage_attachments, :locale if column_exists?(:active_storage_attachments, :locale)
end
end
116 changes: 116 additions & 0 deletions docs/developer/translatable_attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
## Translatable Attachments (developer guide)

Overview
--------

This guide documents the Mobility-style solution for per-locale Active Storage
attachments used in the project. Implementation highlights:

- Adds `locale:string` to `active_storage_attachments` and backfills to the
default locale.
- Provides a Mobility backend in `lib/mobility/backends/attachments/backend.rb`
which defines per-attribute, per-locale associations and accessors.
- Includes a small DSL at `lib/mobility/dsl/attachments.rb` to apply the
backend setup immediately at class-definition time (`translates_attached`).

Where to look
-------------

- Backend implementation: `lib/mobility/backends/attachments/backend.rb`
- DSL for early use: `lib/mobility/dsl/attachments.rb` (`Mobility::DSL::Attachments`)
- Initializer wiring: `config/initializers/mobility.rb` (requires + registers
the backend and loads the DSL)
- Migration: `db/migrate/20250829000000_add_locale_to_active_storage_attachments.rb`
- Example form partial: `app/views/better_together/shared/_translated_file_field.html.erb`

Model usage
-----------

Canonical (preferred when backend registration runs early):

class Page < ApplicationRecord
translates :hero_image, backend: :attachments, content_type: [/image/], presence: true
end

Early-definition (tests, engines):

class Page < ApplicationRecord
extend Mobility::DSL::Attachments
translates_attached :hero_image, content_type: [/image/], presence: true
end

The backend will generate accessors like `hero_image_en`, `hero_image_en=`,
`hero_image_en?`, and `hero_image_en_url` for each configured locale.

Migration notes
---------------

Use a staged migration: add a nullable `locale` column, backfill existing
attachment rows to the default locale, then set NOT NULL and add a unique
index on `[:record_type, :record_id, :name, :locale]`.

Testing
-------

- Unit: `spec/lib/mobility_attachments_backend_spec.rb` demonstrates the
generation of accessors. The test uses `translates_attached` to guarantee
generation timing in the test environment.
- Integration: Add feature specs to exercise form uploads using the
`_translated_file_field` partial and verify controller param handling.

Developer tips
--------------

- Prefer the canonical `translates` API when possible. The DSL is intended
for boot-order-sensitive contexts.
- Validate `content_type` and `presence` server-side via the backend options.
- When deploying, ensure migrations are run and backfills complete before
enabling any model to rely on the `locale` column being present and NOT NULL.

## Process Flow Diagram

```mermaid
%% Translatable Attachments flow
flowchart LR
subgraph DB[Database]
A[active_storage_attachments table]
end

M[Migration adds `locale` column & backfill] --> A

Init[Mobility initializer]
Init -->|require backend| B[Attachments backend (lib/mobility/backends/...)]
Init -->|register backend| Mobility[Mobility.register_backend(:attachments)]

B -->|provides| Apply[AttachmentsBackend.apply_to / setup]

ModelCanonical[Model: `translates :hero_image, backend: :attachments`]
ModelDSL[Model: `extend Mobility::DSL::Attachments`\n`translates_attached :hero_image`]

ModelCanonical --> Apply
ModelDSL --> Apply

Apply -->|defines associations| Assoc[has_many :hero_image_attachments_all\nhas_one :hero_image_attachment]
Apply -->|defines accessors| Accessors[hero_image_en, hero_image_en=, hero_image_en?, hero_image_en_url]

View[Form partial: translated file field tabs]
View -->|uploads per-locale| Controller

Controller[Controller] -->|permits locale params or maps| Model
Model -->|writer methods| ActiveStorage[Create/modify ActiveStorage::Attachment rows]
ActiveStorage --> A

Serve[URL helper / rails_blob_url] -->|serves blob| UserBrowser[User's browser]

Accessors -->|getter fallback| Fallback[Default-locale fallback if enabled]
Fallback --> ActiveStorage

classDef infra fill:#f8f9fa,stroke:#333,stroke-width:1px;
class DB,Init,B,Apply,Assoc,Accessors,View,Controller,ActiveStorage,Serve,Fallback infra;
```

Diagram files:

- Mermaid source: `docs/diagrams/source/translatable_attachments_flow.mmd` - editable source
- (Optional) PNG/SVG exports: `docs/diagrams/exports/...` - add via `bin/render_diagrams` if you generate exports

35 changes: 35 additions & 0 deletions docs/diagrams/source/translatable_attachments.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
%% Translatable Attachments - Entity Relationship
%% Generated: 2025-08-29

classDiagram
direction TB

class ActiveStorage::Blob {
+uuid id
+string filename
+string content_type
+integer byte_size
+json metadata
}

class ActiveStorage::Attachment {
+uuid id
+string name
+string record_type
+uuid record_id
+uuid blob_id
+string locale
+datetime created_at
}

class Page {
+uuid id
+string title
}

Page "1" o-- "*" ActiveStorage::Attachment : has_many
ActiveStorage::Attachment "*" --> "1" ActiveStorage::Blob : belongs_to

%% locale uniqueness
note right of ActiveStorage::Attachment: Unique index on
note right of ActiveStorage::Attachment: (record_type, record_id, name, locale)
37 changes: 37 additions & 0 deletions docs/diagrams/source/translatable_attachments_flow.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
%% Translatable Attachments flow
flowchart LR
subgraph DB[Database]
A[active_storage_attachments table]
end

M[Migration adds `locale` column & backfill] --> A

Init[Mobility initializer]
Init -->|require backend| B[Attachments backend (lib/mobility/backends/...)]
Init -->|register backend| Mobility[Mobility.register_backend(:attachments)]

B -->|provides| Apply[AttachmentsBackend.apply_to / setup]

ModelCanonical[Model: `translates :hero_image, backend: :attachments`]
ModelDSL[Model: `extend Mobility::DSL::Attachments`\n`translates_attached :hero_image`]

ModelCanonical --> Apply
ModelDSL --> Apply

Apply -->|defines associations| Assoc[has_many :hero_image_attachments_all\nhas_one :hero_image_attachment]
Apply -->|defines accessors| Accessors[hero_image_en, hero_image_en=, hero_image_en?, hero_image_en_url]

View[Form partial: translated file field tabs]
View -->|uploads per-locale| Controller

Controller[Controller] -->|permits locale params or maps| Model
Model -->|writer methods| ActiveStorage[Create/modify ActiveStorage::Attachment rows]
ActiveStorage --> A

Serve[URL helper / rails_blob_url] -->|serves blob| UserBrowser[User's browser]

Accessors -->|getter fallback| Fallback[Default-locale fallback if enabled]
Fallback --> ActiveStorage

classDef infra fill:#f8f9fa,stroke:#333,stroke-width:1px;
class DB,Init,B,Apply,Assoc,Accessors,View,Controller,ActiveStorage,Serve,Fallback infra;
39 changes: 39 additions & 0 deletions docs/organizers/translatable_attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
## Translatable Attachments — Organizer Guide

Purpose
-------

Organizers can upload images or files that are specific to a language or
locale. The translatable attachments feature stores one attachment per locale
so you can present localized media to your users.

How to use
----------

When editing content in the admin interface, you'll see a tabbed file upload
field labelled by language (for example, "English", "Français"). Upload a
file in each tab to provide localized media.

Behavior
--------

- The site will use the file for the requested locale if present.
- If a file for the requested locale is not present, the system may fall back
to the default language's file (this is configurable by developers).
- When you remove a file from a locale, that locale-specific attachment will
be deleted; other locales are unaffected.

Best practices
--------------

- Provide translations for alt text and captions to match the localized
media — the attachment itself is just the binary file.
- Keep file sizes optimized for the web; large images slow down page loads.

Troubleshooting
---------------

- If you don't see the language tabs, ensure you have appropriate
permissions and that your organization has multiple locales enabled.
- If uploads fail, check file type restrictions (some admins forbid certain
file types) and contact the technical team.
Loading
Loading