diff --git a/app/notifiers/better_together/event_invitation_notifier.rb b/app/notifiers/better_together/event_invitation_notifier.rb index b766b65d0..6f97f6f39 100644 --- a/app/notifiers/better_together/event_invitation_notifier.rb +++ b/app/notifiers/better_together/event_invitation_notifier.rb @@ -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 %s', event_name: event&.name) - # rubocop:enable Lint/DuplicateHashKey + event_name: event&.name, default: 'Invitation to %s') end def build_message(_notification) diff --git a/app/views/better_together/shared/_translated_file_field.html.erb b/app/views/better_together/shared/_translated_file_field.html.erb new file mode 100644 index 000000000..90bf22f98 --- /dev/null +++ b/app/views/better_together/shared/_translated_file_field.html.erb @@ -0,0 +1,31 @@ +<%# Render a tabbed file input for each locale. Expects locals: f (form builder), attribute (symbol), model %> +
+ +
+ <% (Mobility.available_locales rescue I18n.available_locales).each_with_index do |locale, i| %> +
" role="tabpanel"> +
+ <% 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 %> +
+
+ <%= f.file_field "#{attribute}_#{locale}", class: 'form-control' %> +
+
+ <% end %> +
+
diff --git a/config/initializers/active_storage_locales.rb b/config/initializers/active_storage_locales.rb new file mode 100644 index 000000000..0efc3205f --- /dev/null +++ b/config/initializers/active_storage_locales.rb @@ -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 diff --git a/config/initializers/mobility.rb b/config/initializers/mobility.rb index e777f64df..14169baa5 100644 --- a/config/initializers/mobility.rb +++ b/config/initializers/mobility.rb @@ -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 diff --git a/db/migrate/20250829000000_add_locale_to_active_storage_attachments.rb b/db/migrate/20250829000000_add_locale_to_active_storage_attachments.rb new file mode 100644 index 000000000..ec8c93a5c --- /dev/null +++ b/db/migrate/20250829000000_add_locale_to_active_storage_attachments.rb @@ -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 diff --git a/db/migrate/20250901203000_add_children_count_to_checklist_items.rb b/db/migrate/20250901203000_add_children_count_to_checklist_items.rb deleted file mode 100644 index 54387a86c..000000000 --- a/db/migrate/20250901203000_add_children_count_to_checklist_items.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -# Migration to add a counter cache column for number of children on checklist items. -class AddChildrenCountToChecklistItems < ActiveRecord::Migration[7.1] - disable_ddl_transaction! - - def change - # Only add the column/index if they don't already exist (safe reruns) - unless column_exists?(:better_together_checklist_items, :children_count) - add_column :better_together_checklist_items, :children_count, :integer, default: 0, null: false - add_index :better_together_checklist_items, :children_count - end - - reversible do |dir| - dir.up do - backfill_children_count if column_exists?(:better_together_checklist_items, :parent_id) - end - end - end - - private - - def backfill_children_count # rubocop:disable Metrics/MethodLength - # Backfill counts without locking the whole table - execute(<<-SQL.squish) - UPDATE better_together_checklist_items parent - SET children_count = sub.count - FROM ( - SELECT parent_id, COUNT(*) as count - FROM better_together_checklist_items - WHERE parent_id IS NOT NULL - GROUP BY parent_id - ) AS sub - WHERE parent.id = sub.parent_id - SQL - end -end diff --git a/db/migrate/20250901_add_children_count_to_checklist_items.rb b/db/migrate/20250901_add_children_count_to_checklist_items.rb deleted file mode 100644 index 66581c1f0..000000000 --- a/db/migrate/20250901_add_children_count_to_checklist_items.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -# Migration to add children_count column and backfill existing counts for checklist items. -class AddChildrenCountToChecklistItems < ActiveRecord::Migration[7.1] - disable_ddl_transaction! - - def change - add_column :better_together_checklist_items, :children_count, :integer, default: 0, null: false - add_index :better_together_checklist_items, :children_count - - reversible do |dir| - dir.up { backfill_children_count } - end - end - - private - - def backfill_children_count # rubocop:disable Metrics/MethodLength - execute(<<-SQL.squish) - UPDATE better_together_checklist_items parent - SET children_count = sub.count - FROM ( - SELECT parent_id, COUNT(*) as count - FROM better_together_checklist_items - WHERE parent_id IS NOT NULL - GROUP BY parent_id - ) AS sub - WHERE parent.id = sub.parent_id - SQL - end -end diff --git a/db/migrate/20250901_add_parent_to_checklist_items.rb b/db/migrate/20250901_add_parent_to_checklist_items.rb deleted file mode 100644 index f690c8806..000000000 --- a/db/migrate/20250901_add_parent_to_checklist_items.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -# Migration to add a self-referential parent reference to checklist items. -class AddParentToChecklistItems < ActiveRecord::Migration[7.1] - def change - add_reference :better_together_checklist_items, - :parent, - type: :uuid, - foreign_key: { to_table: :better_together_checklist_items }, - index: true - end -end diff --git a/docs/developer/translatable_attachments.md b/docs/developer/translatable_attachments.md new file mode 100644 index 000000000..f120c12cf --- /dev/null +++ b/docs/developer/translatable_attachments.md @@ -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 + diff --git a/docs/diagrams/source/translatable_attachments.mmd b/docs/diagrams/source/translatable_attachments.mmd new file mode 100644 index 000000000..b876082fc --- /dev/null +++ b/docs/diagrams/source/translatable_attachments.mmd @@ -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) diff --git a/docs/diagrams/source/translatable_attachments_flow.mmd b/docs/diagrams/source/translatable_attachments_flow.mmd new file mode 100644 index 000000000..59f117bfc --- /dev/null +++ b/docs/diagrams/source/translatable_attachments_flow.mmd @@ -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; diff --git a/docs/organizers/translatable_attachments.md b/docs/organizers/translatable_attachments.md new file mode 100644 index 000000000..3364fabf1 --- /dev/null +++ b/docs/organizers/translatable_attachments.md @@ -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. diff --git a/docs/translatable_attachments.md b/docs/translatable_attachments.md new file mode 100644 index 000000000..3cebc5a84 --- /dev/null +++ b/docs/translatable_attachments.md @@ -0,0 +1,217 @@ +## Translatable Attachments (Mobility-style) + +Overview +-------- + +This document describes the project's Mobility-style solution for per-locale +Active Storage attachments. The implementation mirrors mobility-actiontext: we +add a `locale` column to `active_storage_attachments` and provide a Mobility +backend which meta-defines per-attribute, per-locale accessors on models. + +Files changed / key artifacts +----------------------------- + +- Migration (staged): `db/migrate/20250829000000_add_locale_to_active_storage_attachments.rb` + - Adds `locale:string` to `active_storage_attachments`, backfills existing + rows to the default locale, sets NOT NULL, and adds a unique index on + `[:record_type, :record_id, :name, :locale]`. +- Initializer: `config/initializers/active_storage_locales.rb` + - Adds `for_locale` scope and basic presence validation helper for + `ActiveStorage::Attachment` (guarded so pre-migration loads won't break). +- Mobility backend: `lib/mobility/backends/attachments/backend.rb` + - Central backend implementation. Provides `apply_to` to install + per-attribute associations and per-locale accessors, plus a `setup` hook + so it integrates with `translates ... backend: :attachments`. +- DSL shim: `lib/mobility/dsl/attachments.rb` + - Lightweight DSL `Mobility::DSL::Attachments#translates_attached` for + early model-definition-time use (tests, engines). Preferred runtime API is + still `translates :attr, backend: :attachments` when backend registration + is available early in the boot process. +- Mobility initializer: `config/initializers/mobility.rb` + - Requires and attempts to register the backend early. Also loads the DSL + and extends `ActiveRecord::Base` so models can call `translates_attached`. +- View partial: `app/views/better_together/shared/_translated_file_field.html.erb` + - Reusable tabbed file-field partial that follows the project's translated + field UI patterns. + +Usage (models) +-------------- + +Preferred (canonical) — when the backend is registered early in boot: + + class Page < ApplicationRecord + translates :hero_image, backend: :attachments, content_type: [/image/], presence: true + # Translatable Attachments + + This document explains the project's Mobility-style solution for per-locale + Active Storage attachments and documents the implementation details introduced + in the recent changes. + + ## Overview + + Translatable attachments allow a model to have an independent Active Storage + attachment per locale. The implementation adds a `locale` column to + `active_storage_attachments` and provides a Mobility backend that generates + per-attribute, per-locale attachment accessors on models. + + Key goals: + - One attachment per `record`/`name`/`locale` (for example `hero_image` in + `:en` and `:es`). + - Preserve the familiar Active Storage API on models (`hero_image`, + `hero_image=`, `hero_image_url`, `hero_image?`). + - Provide explicit per-locale accessors (e.g. `hero_image_en`). + - Make the writer robust so attachments are created/updated via associations + and `record_id` (UUID) is always set. + + ## Key files and where to look + + - `lib/mobility/backends/attachments/backend.rb` — Mobility backend that + dynamically generates per-locale getters, writers, predicates, associations, + and URL helpers. The writer accepts many attachable shapes (Blob, IO, + Hash, file path) and creates attachments through the `has_many` association + so `record_id` is populated. + - `lib/mobility/dsl/attachments.rb` — small DSL exposing + `translates_attached` for immediate, class-time wiring (useful in test or + engine bootstraps). Prefer `translates ... backend: :attachments` when the + backend is registered earlier in initializers. + - `db/migrate/20250829000000_add_locale_to_active_storage_attachments.rb` — + migration that adds the `locale` column, backfills, and adds a unique + index for `%i[record_type record_id name locale]`. + - Specs: + - `spec/models/translatable_attachments_writer_spec.rb` + - `spec/models/translatable_attachments_api_spec.rb` + - `spec/features/translatable_attachments_integration_spec.rb` + + ## How it works (high level) + + - For each configured attribute (e.g. `:hero_image`) the backend defines: + - `has_many :hero_image_attachments_all` — all locales (admin management). + - `has_one :hero_image_attachment` (scoped to current `Mobility.locale`). + - Per-locale accessors: `hero_image_en`, `hero_image_en=`, `hero_image_en?`, + and `hero_image_en_url`. + - Non-locale delegators: `hero_image` delegates to the accessor for the + current `Mobility.locale`. + + - Writer behavior: + - Accepts `ActiveStorage::Blob`, attached wrappers, IO-like objects, + Hashes with `:io`/`:filename`/`:content_type`, or file paths. + - If an attachment for the locale exists, the writer updates the + `blob` on the row. Otherwise it creates the attachment via the + `has_many` association so ActiveRecord sets `record_id` correctly. + - Assigning `nil` purges and destroys the localized attachment row. + + ## Model usage + + Preferred (when backend is registered in initializers): + + ```ruby + class Page < ApplicationRecord + translates :hero_image, backend: :attachments, content_type: [/^image\//], presence: true + end + ``` + + Fallback DSL (when immediate generation is required, e.g. in tests or engine + dummy apps): + + ```ruby + class Page < ApplicationRecord + extend Mobility::DSL::Attachments + translates_attached :hero_image, content_type: [/^image\//], presence: true + end + ``` + + API surface examples: + - `page.hero_image_en` — returns `ActiveStorage::Attachment` or `nil`. + - `page.hero_image` — delegates to `page.hero_image_`. + - `page.hero_image_url(host: ...)` — returns blob URL, optionally for a + variant: `page.hero_image_en_url(variant: { resize_to_limit: [300, 300] })`. + + ## Migration details and rollout + + Migration strategy used in the repo (staged, safe for rolling deploys): + 1. Add nullable `locale` string column to `active_storage_attachments`. + 2. Backfill existing rows to `I18n.default_locale` using a single SQL update. + 3. Set `locale` to NOT NULL with a default backfilled value. + 4. Add a unique index on `%i[record_type record_id name locale]` to enforce + a single attachment per locale. + + Notes: + - Use `id: :uuid` as necessary in test/dummy tables to match production. + - Back up production DB before running backfills on large tables. + + ## Testing + + - Run focused specs with the project's Docker helper: + + ```bash + bin/dc-run bundle exec rspec spec/models/translatable_attachments_writer_spec.rb + bin/dc-run bundle exec rspec spec/features/translatable_attachments_integration_spec.rb + ``` + + - The writer specs create real `ActiveStorage::Blob` objects and validate + writer behavior for different attachable inputs. Integration specs attach + a real blob and verify `rails_blob_url` behavior. + - The test helper `spec/rails_helper.rb` was updated to accept keyword options + for `create_table` so specs can use `id: :uuid`. + + ## Linting / RuboCop rationale + + The backend concentrates dynamic method generation in one file which naturally + triggers RuboCop metrics (class/method length, complexity, ABC size). To + reduce noise and keep the implementation readable we included a focused + `rubocop:disable` header with a short justification at the top of the backend + file and a matching `rubocop:enable` at the end of the file. This is + documented in the backend source and in this doc. If you'd prefer, we can + refactor the backend into smaller helpers to remove the disables. + + ## Maintenance and upgrade notes + + - If Active Storage or Mobility changes internal APIs, verify these areas: + - `attachment_reflections` usage (the backend injects a minimal reflection + shim to satisfy Active Storage callbacks). + - `ActiveStorage::Blob.create_and_upload!` semantics. + + - When updating this feature, run: + + ```bash + bin/dc-run bundle exec rspec + bin/dc-run bundle exec rubocop -A + bin/dc-run bundle exec brakeman --quiet --no-pager + ``` + + ## Docs/diagrams + + - If you add diagrams, put Mermaid sources under + `docs/diagrams/source/translatable_attachments.mmd` and export to + `docs/diagrams/exports/` using `bin/render_diagrams`. + + Diagram + ------- + + Mermaid source for the translatable-attachments ER diagram is available at: + + `docs/diagrams/source/translatable_attachments.mmd` + + You can render exports by running the project's `bin/render_diagrams` helper + which writes PNG/SVG exports into `docs/diagrams/exports/`. + + ## Example (console) + + ```ruby + page = Page.create!(title: 'Home') + page.hero_image = File.open('spec/fixtures/images/sample.png') + page.save! + page.hero_image # => attachment for current locale + I18n.with_locale(:es) { page.hero_image } # => fallback to default if no Spanish attachment + ``` + + ## Next steps / options + + - Open a PR with the code + docs changes (I can create it for you). + - Add a Mermaid diagram and wire it into the docs index. + - Refactor the backend to remove RuboCop disables (larger effort). + + --- + + If you'd like I can open a PR with these docs and the earlier code changes, or + start the refactor to remove the RuboCop disables — tell me which you prefer. diff --git a/docs/users/translatable_attachments.md b/docs/users/translatable_attachments.md new file mode 100644 index 000000000..f274b5545 --- /dev/null +++ b/docs/users/translatable_attachments.md @@ -0,0 +1,25 @@ +## What are localized images and files (for users) + +On this site, some images and files are language-specific. That means when you +view the site in a particular language, you'll see images and documents that +match that language. + +How it works for you +-------------------- + +- When you switch the site language, some pictures or downloadable files may + change to better match your language. +- If a language-specific file isn't available, the site may show the default + language's file instead. + +Why this helps +-------------- + +- Localized images improve clarity and accessibility (e.g., images containing + text or culturally-specific illustrations). + +If something looks wrong +----------------------- + +- If an image appears in the wrong language, please report it using the site + feedback form and include the page URL and which language you were viewing. diff --git a/lib/mobility/backends/attachments.rb b/lib/mobility/backends/attachments.rb new file mode 100644 index 000000000..ebbaeca42 --- /dev/null +++ b/lib/mobility/backends/attachments.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'mobility/backend' + +module Mobility + module Backends + # Mobility backend for localized attachments using ActiveStorage::Attachment with locale column + module Attachments + extend ActiveSupport::Concern + + class_methods do + def valid_keys + [:fallback] + end + + def setup(&) + # this method is required by Mobility backend pattern; actual setup is performed in configure + super if defined?(super) + end + end + + # Called by Mobility when including backend on a model + def self.setup + # placeholder for compatibility + end + end + end +end diff --git a/lib/mobility/backends/attachments/backend.rb b/lib/mobility/backends/attachments/backend.rb new file mode 100644 index 000000000..ddd99d36b --- /dev/null +++ b/lib/mobility/backends/attachments/backend.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +# Mobility Attachments backend +# This backend dynamically defines per-attribute, per-locale attachment accessors. +# It is intentionally large because it generates many methods on host models. +# +# NOTE: This file performs a fair amount of dynamic method generation to wire +# translated ActiveStorage attachment accessors on host models. That design +# concentrates complexity into a single, intentionally large implementation. +# To keep the implementation clear and avoid noisy metric offenses from +# RuboCop (which are not helpful for this dynamic code), we disable a small +# set of metric cops for this file. Please don't re-enable them here unless +# you refactor into smaller, testable helpers. +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/BlockLength, Metrics/BlockNesting, Layout/LineLength + +# Documentation: Mobility::Backends - namespace for Mobility storage backends. +require 'mobility/backend' +module Mobility + # Namespace for Mobility storage backends. + # Backends implement storage and retrieval strategies for translated data. + module Backends + # Backend that provides translated attachment accessors. + class AttachmentsBackend + include Mobility::Backend + + # Shared implementation for applying the attachment translation setup to a model. + # Extracted so external shims (e.g. translates_attached) can reuse the logic. + def self.apply_to(model_class, attributes, options) + # ensure class_attribute exists to track configured attachments + unless model_class.respond_to?(:mobility_translated_attachments) + model_class.class_attribute :mobility_translated_attachments, instance_predicate: false, + instance_accessor: false + model_class.mobility_translated_attachments = {} + end + + attributes.each do |attr_name| + name = attr_name.to_s + model_class.mobility_translated_attachments = model_class.mobility_translated_attachments.merge(name.to_sym => options) + + # Process options: support :content_type (array/regex) and :presence (boolean) + content_types = options[:content_type] + require_presence = options[:presence] + + if content_types || require_presence + # inject validation helpers into model + model_class.validate do + att = ActiveStorage::Attachment.where(record: self, name: name, locale: Mobility.locale.to_s).first + errors.add(name.to_sym, :blank) if require_presence && att.nil? + if att && content_types + ct = att.blob&.content_type + unless Array(content_types).any? do |pat| + if pat.is_a?(Regexp) + pat.match?(ct) + else + pat == ct + end + end + errors.add(name.to_sym, :invalid, message: 'invalid content type') + end + end + end + end + + # define an association that returns the attachment for current Mobility.locale + assoc_name = "#{name}_attachment" + + # define has_many for all locales (admin management) + model_class.has_many(:"#{name}_attachments_all", -> { where(name: name) }, + class_name: 'ActiveStorage::Attachment', as: :record, inverse_of: :record, dependent: :destroy) + + # define a has_one for current locale using a lambda scope that uses Mobility.locale + model_class.has_one(assoc_name.to_sym, -> { where(name: name, locale: Mobility.locale.to_s) }, + class_name: 'ActiveStorage::Attachment', as: :record, inverse_of: :record, autosave: true, dependent: :destroy) + + # define eager-load scope + scope_name = "with_#{name}_attachment" + model_class.singleton_class.instance_eval do + define_method(scope_name) do + includes(assoc_name.to_sym => { blob_attachment: :blob }) + end + end + + # define per-locale accessors using available locales + locales = if defined?(Mobility) && Mobility.respond_to?(:available_locales) + Mobility.available_locales.map(&:to_sym) + else + I18n.available_locales.map(&:to_sym) + end + + locales.each do |locale| + accessor = Mobility.normalize_locale_accessor(name, locale) + + # getter + model_class.define_method(accessor) do |fallback: true| + # prefer cached association when present + if association_cached?(assoc_name) + att = public_send(assoc_name) + return att if att && att.locale.to_s == locale.to_s + end + + att = ActiveStorage::Attachment.where(record: self, name: name, locale: locale.to_s).first + if att.nil? && fallback + att = ActiveStorage::Attachment.where(record: self, name: name, locale: I18n.default_locale.to_s).first + end + att + end + + # predicate + model_class.define_method("#{accessor}?") do + send(accessor, fallback: false).present? + end + + # writer - create or update an attachment row via the association so `record_id` is always set + model_class.define_method("#{accessor}=") do |attachable| + attachments_assoc = public_send("#{name}_attachments_all") + existing = attachments_assoc.find_by(locale: locale.to_s) + + if attachable.nil? + if existing + existing.purge + existing.destroy + end + next + end + + # Determine blob from various attachable shapes + blob = if attachable.is_a?(ActiveStorage::Blob) + attachable + elsif attachable.respond_to?(:blob) && attachable.blob + attachable.blob + elsif attachable.is_a?(Hash) && attachable[:io] + ActiveStorage::Blob.create_and_upload!(io: attachable[:io], + filename: attachable[:filename] || 'upload', content_type: attachable[:content_type]) + elsif attachable.respond_to?(:read) + filename = attachable.respond_to?(:original_filename) ? attachable.original_filename : 'upload' + content_type = attachable.respond_to?(:content_type) ? attachable.content_type : nil + ActiveStorage::Blob.create_and_upload!(io: attachable, filename: filename, + content_type: content_type) + else + # Fallback: try to coerce via to_path (file path) or raise for unsupported types + unless attachable.respond_to?(:to_path) + raise ArgumentError, "Unsupported attachable type: #{attachable.class}" + end + + file = File.open(attachable.to_path, 'rb') + ActiveStorage::Blob.create_and_upload!(io: file, filename: File.basename(attachable.to_path)) + + end + + if existing + existing.update!(blob: blob) + else + # Use the association create! so ActiveRecord sets record_id correctly + attachments_assoc.create!(blob: blob, name: name, locale: locale.to_s) + end + end + + # url helper + model_class.define_method("#{accessor}_url") do |variant: nil, host: nil, fallback: true| + att = send(accessor, fallback: fallback) + return unless att&.blob + + if variant + Rails.application.routes.url_helpers.rails_representation_url(att.blob.variant(variant).processed, + host: host) + else + Rails.application.routes.url_helpers.rails_blob_url(att.blob, host: host) + end + end + end + + # Non-locale delegating accessors (e.g. hero_image, hero_image=) + # These delegate to the accessor for the current Mobility.locale. + model_class.define_method(name) do |fallback: true| + accessor = Mobility.normalize_locale_accessor(name, Mobility.locale) + send(accessor, fallback: fallback) + end + + model_class.define_method("#{name}=") do |attachable| + accessor = Mobility.normalize_locale_accessor(name, Mobility.locale) + send("#{accessor}=", attachable) + end + + model_class.define_method("#{name}?") do + accessor = Mobility.normalize_locale_accessor(name, Mobility.locale) + send("#{accessor}?") + end + + model_class.define_method("#{name}_url") do |variant: nil, host: nil, fallback: true| + accessor = Mobility.normalize_locale_accessor(name, Mobility.locale) + send("#{accessor}_url", variant: variant, host: host, fallback: fallback) + end + + # Define non-locale delegating accessors that work with the current + # Mobility.locale so that models expose the same API as non-localized + # attachments (e.g. `hero_image`, `hero_image=`, `hero_image?`, `hero_image_url`). + model_class.define_method(name) do |fallback: true| + locale_accessor = Mobility.normalize_locale_accessor(name, Mobility.locale) + send(locale_accessor, fallback: fallback) + end + + model_class.define_method("#{name}=") do |attachable| + locale_writer = "#{Mobility.normalize_locale_accessor(name, Mobility.locale)}=" + send(locale_writer, attachable) + end + + model_class.define_method("#{name}?") do + locale_pred = "#{Mobility.normalize_locale_accessor(name, Mobility.locale)}?" + send(locale_pred) + end + + model_class.define_method("#{name}_url") do |**opts| + locale_url = "#{Mobility.normalize_locale_accessor(name, Mobility.locale)}_url" + send(locale_url, **opts) + end + + # Ensure ActiveStorage callback code that expects attachment_reflections[name] + # to exist does not trip over nil. We inject a small wrapper on the model + # that merges synthetic reflections for each translated attachment name. + next if model_class.singleton_class.instance_variable_defined?(:@_mobility_attachment_reflections_wrapped) + + orig = begin + model_class.method(:attachment_reflections) + rescue StandardError + nil + end + model_class.define_singleton_method(:attachment_reflections) do + base = orig ? orig.call : {} + base = base.dup + if respond_to?(:mobility_translated_attachments) && mobility_translated_attachments + mobility_translated_attachments.each_key do |k| + key = k.to_s + unless base[key] + # Minimal reflection-like object expected by ActiveStorage + base[key] = Struct.new(:options, :named_variants).new({}, {}) + end + end + end + base + end + model_class.singleton_class.instance_variable_set(:@_mobility_attachment_reflections_wrapped, true) + end + end + + # Track configured translated attachments on the model via Mobility.setup + setup do |attributes, options| + self.class.apply_to(model_class, attributes, options) + end + + # read/write are intentionally not implemented here; per-locale accessors + # are generated directly on the host model via the setup block above. + end + + # NOTE: registration is performed by the Mobility initializer to ensure + # Mobility's API is available at registration time. + Mobility::Backends.const_set(:Attachments, AttachmentsBackend) + end +end + +# Re-enable metric cops disabled at the top of this file. +# Re-enable metric cops disabled at the top of this file. +# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize +# rubocop:enable Metrics/BlockLength, Metrics/BlockNesting, Layout/LineLength diff --git a/lib/mobility/dsl/attachments.rb b/lib/mobility/dsl/attachments.rb new file mode 100644 index 000000000..ec5253dbb --- /dev/null +++ b/lib/mobility/dsl/attachments.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Mobility Attachments DSL +# +# Provides a small, explicit DSL for applying localized attachment accessors +# to models at class-definition time. Use this when you need the meta-generated +# accessors to exist immediately (for example in test bootstraps, engines, or +# other early-loading contexts). Prefer calling Mobility's standard +# `translates :attr, backend: :attachments` when the backend registration is +# available early in the boot process. +# +# Examples +# +# class Page < ApplicationRecord +# extend Mobility::DSL::Attachments +# +# # create per-locale accessors for :hero_image using the attachments backend +# translates_attached :hero_image, content_type: [/image/] , presence: true +# end +# +module Mobility + module DSL + # Small DSL module that exposes `translates_attached` for immediate + # application of the attachments backend at class-definition time. + module Attachments + # Apply localized attachment accessors to the model class. + # Accepts the same attribute list and options as the underlying + # AttachmentsBackend.apply_to method. Supported options include + # :content_type and :presence (see backend implementation for details). + def translates_attached(*attributes, **options) + require 'mobility/backends/attachments/backend' + Mobility::Backends::AttachmentsBackend.apply_to(self, attributes, options) + end + end + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 4f04151d0..a69eb114f 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_09_01_203002) do +ActiveRecord::Schema[7.2].define(version: 2025_09_01_203002) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -33,8 +33,10 @@ t.uuid "record_id", null: false t.uuid "blob_id", null: false t.datetime "created_at", null: false + t.string "locale", default: "en", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + t.index ["record_type", "record_id", "name", "locale"], name: "index_active_storage_attachments_on_record_and_name_and_locale", unique: true end create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -219,16 +221,9 @@ t.uuid "creator_id" t.string "identifier", limit: 100, null: false t.string "privacy", limit: 50, default: "private", null: false - t.string "interestable_type" - t.uuid "interestable_id" - t.datetime "starts_at" - t.datetime "ends_at" t.index ["creator_id"], name: "by_better_together_calls_for_interest_creator" - t.index ["ends_at"], name: "bt_calls_for_interest_by_ends_at" t.index ["identifier"], name: "index_better_together_calls_for_interest_on_identifier", unique: true - t.index ["interestable_type", "interestable_id"], name: "index_better_together_calls_for_interest_on_interestable" t.index ["privacy"], name: "by_better_together_calls_for_interest_privacy" - t.index ["starts_at"], name: "bt_calls_for_interest_by_starts_at" end create_table "better_together_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -238,6 +233,7 @@ t.string "identifier", limit: 100, null: false t.integer "position", null: false t.boolean "protected", default: false, null: false + t.boolean "visible", default: true, null: false t.string "type", default: "BetterTogether::Category", null: false t.string "icon", default: "fas fa-icons", null: false t.index ["identifier", "type"], name: "index_better_together_categories_on_identifier_and_type", unique: true @@ -247,12 +243,12 @@ t.integer "lock_version", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "category_type", null: false t.uuid "category_id", null: false t.string "categorizable_type", null: false t.uuid "categorizable_id", null: false + t.string "category_type", null: false t.index ["categorizable_type", "categorizable_id"], name: "index_better_together_categorizations_on_categorizable" - t.index ["category_type", "category_id"], name: "index_better_together_categorizations_on_category" + t.index ["category_id"], name: "index_better_together_categorizations_on_category_id" end create_table "better_together_checklist_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -715,6 +711,8 @@ t.uuid "invitee_id" t.string "invitee_email", null: false t.uuid "role_id" + t.uuid "primary_invitation_id" + t.integer "session_duration_mins", default: 30, null: false t.index ["invitable_id", "status"], name: "invitations_on_invitable_id_and_status" t.index ["invitable_type", "invitable_id"], name: "by_invitable" t.index ["invitee_email", "invitable_id"], name: "invitations_on_invitee_email_and_invitable_id", unique: true @@ -723,6 +721,7 @@ t.index ["invitee_type", "invitee_id"], name: "by_invitee" t.index ["inviter_type", "inviter_id"], name: "by_inviter" t.index ["locale"], name: "by_better_together_invitations_locale" + t.index ["primary_invitation_id"], name: "index_better_together_invitations_on_primary_invitation_id" t.index ["role_id"], name: "by_role" t.index ["status"], name: "by_status" t.index ["token"], name: "invitations_by_token", unique: true @@ -872,20 +871,6 @@ t.index ["pageable_type", "pageable_id"], name: "index_better_together_metrics_page_views_on_pageable" end - create_table "better_together_metrics_rich_text_links", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.integer "lock_version", default: 0, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "rich_text_id", null: false - t.string "url", null: false - t.string "link_type", null: false - t.boolean "external", null: false - t.boolean "valid", default: false - t.string "host" - t.text "error_message" - t.index ["rich_text_id"], name: "index_better_together_metrics_rich_text_links_on_rich_text_id" - end - create_table "better_together_metrics_search_queries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.integer "lock_version", default: 0, null: false t.datetime "created_at", null: false @@ -957,10 +942,10 @@ t.datetime "updated_at", null: false t.string "identifier", limit: 100, null: false t.boolean "protected", default: false, null: false - t.string "privacy", limit: 50, default: "private", null: false t.text "meta_description" t.string "keywords" t.datetime "published_at" + t.string "privacy", default: "private", null: false t.string "layout" t.string "template" t.uuid "sidebar_nav_id" @@ -1025,29 +1010,6 @@ t.index ["role_id"], name: "person_community_membership_by_role" end - create_table "better_together_person_platform_integrations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.integer "lock_version", default: 0, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "provider", limit: 50, default: "", null: false - t.string "uid", limit: 50, default: "", null: false - t.string "name" - t.string "handle" - t.string "profile_url" - t.string "image_url" - t.string "access_token" - t.string "access_token_secret" - t.string "refresh_token" - t.datetime "expires_at" - t.jsonb "auth" - t.uuid "person_id" - t.uuid "platform_id" - t.uuid "user_id" - t.index ["person_id"], name: "bt_person_platform_conections_by_person" - t.index ["platform_id"], name: "bt_person_platform_conections_by_platform" - t.index ["user_id"], name: "bt_person_platform_conections_by_user" - end - create_table "better_together_person_platform_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.integer "lock_version", default: 0, null: false t.datetime "created_at", null: false @@ -1118,7 +1080,7 @@ t.index ["invitee_email"], name: "platform_invitations_by_invitee_email" t.index ["invitee_id"], name: "platform_invitations_by_invitee" t.index ["inviter_id"], name: "platform_invitations_by_inviter" - t.index ["locale"], name: "by_better_together_platform_invitations_locale" + t.index ["locale"], name: "platform_invitations_by_locale" t.index ["platform_role_id"], name: "platform_invitations_by_platform_role" t.index ["status"], name: "platform_invitations_by_status" t.index ["token"], name: "platform_invitations_by_token", unique: true @@ -1135,6 +1097,7 @@ t.boolean "protected", default: false, null: false t.uuid "community_id", null: false t.string "privacy", limit: 50, default: "private", null: false + t.string "slug" t.string "url", null: false t.string "time_zone", null: false t.jsonb "settings", default: {}, null: false @@ -1210,31 +1173,6 @@ t.index ["resource_type", "position"], name: "index_roles_on_resource_type_and_position", unique: true end - create_table "better_together_seeds", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.integer "lock_version", default: 0, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "type", default: "BetterTogether::Seed", null: false - t.string "seedable_type" - t.uuid "seedable_id" - t.uuid "creator_id" - t.string "identifier", limit: 100, null: false - t.string "privacy", limit: 50, default: "private", null: false - t.string "version", null: false - t.string "created_by", null: false - t.datetime "seeded_at", null: false - t.text "description", null: false - t.jsonb "origin", null: false - t.jsonb "payload", null: false - t.index ["creator_id"], name: "by_better_together_seeds_creator" - t.index ["identifier"], name: "index_better_together_seeds_on_identifier", unique: true - t.index ["origin"], name: "index_better_together_seeds_on_origin", using: :gin - t.index ["payload"], name: "index_better_together_seeds_on_payload", using: :gin - t.index ["privacy"], name: "by_better_together_seeds_privacy" - t.index ["seedable_type", "seedable_id"], name: "index_better_together_seeds_on_seedable" - t.index ["type", "identifier"], name: "index_better_together_seeds_on_type_and_identifier", unique: true - end - create_table "better_together_social_media_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.integer "lock_version", default: 0, null: false t.datetime "created_at", null: false diff --git a/spec/features/translatable_attachments_integration_spec.rb b/spec/features/translatable_attachments_integration_spec.rb new file mode 100644 index 000000000..78ae57f3b --- /dev/null +++ b/spec/features/translatable_attachments_integration_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/ExampleLength + +require 'rails_helper' + +RSpec.describe 'Translatable Attachments integration', type: :model do + before do + ActiveRecord::Base.connection.create_table(:translatable_attachment_integration_dummies, id: :uuid) do |t| + t.string :name + end + + klass = Class.new(ActiveRecord::Base) do + self.table_name = 'translatable_attachment_integration_dummies' + extend Mobility::DSL::Attachments + + translates_attached :hero_image + end + stub_const('TranslatableAttachmentIntegrationDummy', klass) + end + + after do + drop_table :translatable_attachment_integration_dummies + rescue StandardError + nil + end + + let(:model_class) { TranslatableAttachmentIntegrationDummy } + let(:insert_attachment_sql) do + lambda do |instance, blob, locale = 'en'| + sql = <<-SQL + INSERT INTO active_storage_attachments (id, name, record_type, record_id, blob_id, created_at, locale) + VALUES (gen_random_uuid(), 'hero_image', #{ActiveRecord::Base.connection.quote(instance.class.name)}, #{ActiveRecord::Base.connection.quote(instance.id.to_s)}, #{ActiveRecord::Base.connection.quote(blob.id.to_s)}, NOW(), #{ActiveRecord::Base.connection.quote(locale)}) + SQL + ActiveRecord::Base.connection.execute(sql) + end + end + + it 'attaches a real blob and the getter returns the attachment' do + instance = model_class.create!(name: 'test') + blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new('PNGDATA'), filename: 'test.png', + content_type: 'image/png') + insert_attachment_sql.call(instance, blob) + + instance.reload + I18n.with_locale(:en) do + expect(instance.hero_image).to be_present + end + end + + it 'returns a rails_blob_url for attached blob' do + instance = model_class.create!(name: 'test2') + blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new('PNGDATA2'), filename: 'test2.png', + content_type: 'image/png') + insert_attachment_sql.call(instance, blob) + + instance.reload + I18n.with_locale(:en) do + expect(instance.hero_image_url(host: 'http://example.com')).to include('http://example.com') + end + end +end + +# rubocop:enable RSpec/ExampleLength diff --git a/spec/lib/mobility_attachments_backend_spec.rb b/spec/lib/mobility_attachments_backend_spec.rb new file mode 100644 index 000000000..a7ae5f1e7 --- /dev/null +++ b/spec/lib/mobility_attachments_backend_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/ExampleLength, RSpec/MultipleExpectations + +require 'rails_helper' + +RSpec.describe 'Mobility Attachments Backend (prototype)', type: :model do + before do + # Create a temporary table for the dummy model + ActiveRecord::Base.connection.create_table(:mobility_attachment_dummies, id: :uuid) do |t| + t.string :dummy + end + + dummy = Class.new(ActiveRecord::Base) do + self.table_name = 'mobility_attachment_dummies' + extend Mobility + + begin + translates_attached :hero_image + rescue StandardError => e + # noop if shim not available in this bootstrap + Rails.logger.debug "translates_attached unavailable: #{e.message}" + end + end + stub_const('MobilityAttachmentDummy', dummy) + end + + after do + drop_table :mobility_attachment_dummies + rescue StandardError + nil + end + + it 'defines localized accessors for configured attribute' do + locales = begin + Mobility.available_locales + rescue StandardError + I18n.available_locales + end + locales.each do |locale| + accessor = Mobility.normalize_locale_accessor('hero_image', locale) + expect(MobilityAttachmentDummy).to be_method_defined(accessor) + expect(MobilityAttachmentDummy).to be_method_defined("#{accessor}=") + expect(MobilityAttachmentDummy).to be_method_defined("#{accessor}?") + expect(MobilityAttachmentDummy).to be_method_defined("#{accessor}_url") + end + end +end + +# rubocop:enable RSpec/ExampleLength, RSpec/MultipleExpectations diff --git a/spec/models/translatable_attachments_api_spec.rb b/spec/models/translatable_attachments_api_spec.rb new file mode 100644 index 000000000..ac32a5423 --- /dev/null +++ b/spec/models/translatable_attachments_api_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/ExampleLength, RSpec/MultipleExpectations, RSpec/DescribeClass + +require 'rails_helper' + +RSpec.describe 'Translatable Attachments API parity' do + before do + # Create a temporary table for the dummy model + ActiveRecord::Base.connection.create_table(:translatable_attachment_dummies, id: :uuid) do |t| + t.string :dummy + end + + dummy = Class.new(ActiveRecord::Base) do + self.table_name = 'translatable_attachment_dummies' + extend Mobility::DSL::Attachments + + translates_attached :hero_image + end + stub_const('TranslatableAttachmentDummy', dummy) + end + + after do + drop_table :translatable_attachment_dummies + rescue StandardError + nil + end + + let(:model_class) { TranslatableAttachmentDummy } + let(:locales) do + Mobility.available_locales + rescue StandardError + I18n.available_locales + end + + it 'defines locale-specific and non-locale accessors' do + locales.each do |locale| + la = Mobility.normalize_locale_accessor('hero_image', locale) + expect(model_class).to be_method_defined(la) + expect(model_class).to be_method_defined("#{la}=") + expect(model_class).to be_method_defined("#{la}?") + expect(model_class).to be_method_defined("#{la}_url") + end + + # Non-locale delegating methods should be present + expect(model_class).to be_method_defined('hero_image') + expect(model_class).to be_method_defined('hero_image=') + expect(model_class).to be_method_defined('hero_image?') + expect(model_class).to be_method_defined('hero_image_url') + end + + it 'delegates non-locale accessors to current Mobility.locale' do + instance = TranslatableAttachmentDummy.new + # No attachments yet + expect(instance.hero_image).to be_nil + expect(instance).not_to be_hero_image + + # Simulate attaching via locale writer: this is a smoke test for method dispatch + # We won't create an actual blob here; just ensure no NoMethodError and methods route + expect { instance.hero_image_en = nil }.not_to raise_error + expect { instance.hero_image = nil }.not_to raise_error + end +end + +# rubocop:enable RSpec/ExampleLength, RSpec/MultipleExpectations, RSpec/DescribeClass diff --git a/spec/models/translatable_attachments_writer_spec.rb b/spec/models/translatable_attachments_writer_spec.rb new file mode 100644 index 000000000..d46101be3 --- /dev/null +++ b/spec/models/translatable_attachments_writer_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/ExampleLength, RSpec/MultipleExpectations, RSpec/DescribeClass + +require 'rails_helper' + +RSpec.describe 'Translatable Attachments writer' do + before do + ActiveRecord::Base.connection.create_table(:translatable_attachment_writer_dummies, id: :uuid) do |t| + t.string :dummy + end + + klass = Class.new(ActiveRecord::Base) do + self.table_name = 'translatable_attachment_writer_dummies' + extend Mobility::DSL::Attachments + + translates_attached :hero_image + end + stub_const('TranslatableAttachmentWriterDummy', klass) + end + + after do + drop_table :translatable_attachment_writer_dummies + rescue StandardError + nil + end + + let(:model_class) { TranslatableAttachmentWriterDummy } + + it 'attaches an ActiveStorage::Blob via the writer' do + model = model_class.create!(dummy: 'w') + blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new('blob data'), filename: 'blob.txt', + content_type: 'text/plain') + + expect { model.hero_image_en = blob }.not_to raise_error + + att = model.hero_image_en + expect(att).to be_present + expect(att.blob).to eq(blob) + expect(model).to be_hero_image + end + + it 'uploads IO-like objects via the writer' do + model = model_class.create!(dummy: 'io') + io = StringIO.new('io data') + + expect { model.hero_image_en = io }.not_to raise_error + + att = model.hero_image_en + expect(att).to be_present + expect(att.blob).to be_present + expect(att.blob.byte_size).to be > 0 + end + + it 'purges and removes the attachment when assigning nil' do + model = model_class.create!(dummy: 'nil') + blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new('to purge'), filename: 'purge.txt') + model.hero_image_en = blob + + expect(model).to be_hero_image + + expect { model.hero_image_en = nil }.not_to raise_error + + expect(model.hero_image_en).to be_nil + expect(model).not_to be_hero_image + end + + it 'replaces existing attachment and leaves old blob unattached (can be purged)' do + model = model_class.create!(dummy: 'replace') + old_blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new('old'), filename: 'old.txt') + model.hero_image_en = old_blob + + expect(model.hero_image_en.blob).to eq(old_blob) + + new_blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new('new'), filename: 'new.txt') + model.hero_image_en = new_blob + + expect(model.hero_image_en.blob).to eq(new_blob) + # old blob should no longer have attachments + expect(old_blob.attachments.count).to eq(0) + + # if desired, old_blob can be purged + old_blob.purge + expect(ActiveStorage::Blob.find_by(id: old_blob.id)).to be_nil + end + + it 'accepts named variants on the model reflection without raising' do + # inject a named variant object on the reflection so ActiveStorage callbacks can inspect it + ref = model_class.attachment_reflections['hero_image'] + named_variant = Struct.new(:preprocessed?, :transformations).new(false, { resize_to_limit: [50, 50] }) + ref.named_variants['thumb'] = named_variant + + model = model_class.create!(dummy: 'variant') + blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new('img'), filename: 'img.jpg', + content_type: 'image/jpeg') + + expect { model.hero_image_en = blob }.not_to raise_error + expect(model.hero_image_en).to be_present + end + + it 'uses the storage service upload when creating blobs (stubbed service)' do + model = model_class.create!(dummy: 'service') + io = StringIO.new('service data') + + # Spy on the higher-level API so we can assert it was used without stubbing behavior + allow(ActiveStorage::Blob).to receive(:create_and_upload!).and_call_original + expect { model.hero_image_en = io }.not_to raise_error + expect(ActiveStorage::Blob).to have_received(:create_and_upload!).at_least(:once) + end +end + +# rubocop:enable RSpec/ExampleLength, RSpec/MultipleExpectations, RSpec/DescribeClass diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 0d88434ac..eabe23c6f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -171,12 +171,12 @@ def build_with_retry(times: 3) end end -def create_table(table_name, &) - ActiveRecord::Base.connection.create_table(table_name, &) +def create_table(table_name, **, &) + ActiveRecord::Base.connection.create_table(table_name, **, &) end -def drop_table(table_name) - ActiveRecord::Base.connection.drop_table(table_name) +def drop_table(table_name, **) + ActiveRecord::Base.connection.drop_table(table_name, **) end # Helper to ensure essential data is available in tests