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
4 changes: 1 addition & 3 deletions app/notifiers/better_together/event_invitation_notifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ def title

def body
I18n.t('better_together.notifications.event_invitation.body',
# rubocop:todo Lint/DuplicateHashKey
event_name: event&.name, default: 'Invitation to %<event_name>s', event_name: event&.name)
# rubocop:enable Lint/DuplicateHashKey
event_name: event&.name, default: 'Invitation to %<event_name>s')
end

def build_message(_notification)
Expand Down
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>
30 changes: 30 additions & 0 deletions config/initializers/active_storage_locales.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 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) }

before_validation :set_locale, on: :create

def set_locale
return if locale.present?

self.locale = I18n.locale
end
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, default: I18n.default_locale.to_s

# 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
37 changes: 0 additions & 37 deletions db/migrate/20250901203000_add_children_count_to_checklist_items.rb

This file was deleted.

31 changes: 0 additions & 31 deletions db/migrate/20250901_add_children_count_to_checklist_items.rb

This file was deleted.

12 changes: 0 additions & 12 deletions db/migrate/20250901_add_parent_to_checklist_items.rb

This file was deleted.

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)
Loading
Loading