Module: Controllers::Attachable

Overview

Shared controller actions for attaching uploads and library publications
to a context record (Article, Communication, and similar models).

Provides Turbo Stream endpoints to attach uploaded files or existing
publications, remove attachments, search the publications library, and
render the publication-picker offcanvas.

Constant Summary collapse

PUBLICATIONS_PER_PAGE =

Publications per page.

20

Instance Method Summary collapse

Instance Method Details

#attachvoid

This method returns an undefined value.

Attaches uploads to the context record, or stages them for a new record.

Logic Details

Three scenarios are handled based on params[:context_object_id] /
params[:id]: a new record (no valid ID — uploads are created but not
attached, and are submitted via hidden form fields), an invalid record
(ID given but record not found — sets a flash error), or an existing
record (uploads are attached via #perform_attach).



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'app/concerns/controllers/attachable.rb', line 26

def attach
  load_context

  # Set controller_path for route generation in components
  # This handles irregular controllers (e.g., article_trainings vs Article model)
  @controller_path = controller_name

  # Capture parent_form_id if provided (for new records - hidden fields need form attribute)
  @parent_form_id = params[:parent_form_id]

  # Determine the scenario: new record, existing record, or invalid record
  context_object_id = params[:context_object_id] || params[:id]
  is_new_record = context_object_id.blank? || context_object_id.to_i == 0
  is_invalid_record = !is_new_record && @context_object.nil?
  is_existing_record = !is_new_record && @context_object.present?

  # Execute appropriate action based on scenario
  if is_new_record
    # New record: create uploads but don't attach them (will be submitted via hidden fields)
    perform_attach(nil)
  elsif is_invalid_record
    # Existing record ID provided but record not found - set flash error
    flash[:error] = 'Unable to attach: Communication not found. Please refresh the page.'
    @uploads = [] # Ensure @uploads is set for template
  elsif is_existing_record
    # Existing record: attach uploads to the record
    perform_attach(@context_object)
  end

  # Single response block for all scenarios
  # Flash messages are automatically handled by TurboStreamFlashable after_action
  respond_to do |format|
    format.turbo_stream { render 'attachable/attach' }
  end
end

#publication_modalvoid

This method returns an undefined value.

Renders the publication picker offcanvas on-demand into a Turbo Frame.
This avoids nested forms by loading the offcanvas separately.



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'app/concerns/controllers/attachable.rb', line 205

def publication_modal
  load_context
  @controller_path = controller_name

  context_object_id = params[:context_object_id] || @context_object&.id || 0
  @offcanvas_id = "publication-picker-#{context_object_id}"

  render partial: 'attachable/publication_offcanvas',
         layout: false,
         locals: {
           offcanvas_id: @offcanvas_id,
           context_object: @context_object,
           context_class_name: params[:context_class],
           controller_path: @controller_path
         }
end

#remove_attachmentvoid

This method returns an undefined value.

Removes an upload from the context record, deleting it only when safe.

Logic Details

For saved records (params[:context_object_id] present), the upload is
unlinked from the record and only deleted when #perform_remove_attachment
deems it safe. For new/unsaved records, uploaded files can be deleted
(Upload#ok_to_delete? true), but literature/publications should NOT be
deleted. The upload is removed from the DOM via Turbo Stream regardless.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'app/concerns/controllers/attachable.rb', line 72

def remove_attachment
  @upload = Upload.find(params[:upload_id])

  # Load context if provided (for existing records)
  if params[:context_object_id].present?
    load_context
    # For saved records: unlink the upload from the record, and only delete if safe
    # (uploaded files can be deleted, but literature/publications should only be unlinked)
    perform_remove_attachment(@context_object)
  elsif @upload.ok_to_delete? && can?(:destroy, @upload)
    # For new/unsaved records (no context_object_id yet):
    # - Uploaded files: can be safely deleted (ok_to_delete? returns true)
    # - Literature/publications: should NOT be deleted (ok_to_delete? returns false)
    # The upload is removed from the DOM via Turbo Stream regardless
    @upload.destroy
  end

  respond_to do |format|
    format.turbo_stream { render 'attachable/remove_attachment' }
    format.html { redirect_back_or_to(root_path, notice: 'Attachment removed') }
  end
end

#search_libraryvoid

This method returns an undefined value.

Searches the publications library for attachable items.

Logic Details

Uses cursor-based (keyset) pagination on items.id for infinite scroll:
fetches PUBLICATIONS_PER_PAGE + 1 rows to detect a next page and returns
the last item's ID as @cursor. A blank search term yields no results.
The params[:append] flag selects between appending results to the grid
(infinite scroll) and replacing the whole picker frame (fresh search).



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'app/concerns/controllers/attachable.rb', line 108

def search_library
  load_context
  context_object_id = @context_object.try(:id).to_i
  is_append = params[:append].to_s == 'true'

  if params[:term].present?
    # Cursor-based pagination using item.id for keyset navigation
    # Eager load literature and primary_image to avoid N+1 queries
    publications_query = Item.publications.active
                             .joins(:literature)
                             .includes(:literature, :primary_image)
                             .keywords_search(params[:term])
                             .order('items.id DESC')

    # Apply cursor filter if provided (keyset pagination)
    publications_query = publications_query.where(items: { id: ...params[:cursor].to_i }) if params[:cursor].present?

    @publications = publications_query.limit(PUBLICATIONS_PER_PAGE + 1).to_a

    # Check if there are more results
    @has_more = @publications.size > PUBLICATIONS_PER_PAGE
    @publications = @publications.first(PUBLICATIONS_PER_PAGE)

    # Next cursor is the last item's ID
    @cursor = @publications.last&.id
  else
    @publications = []
    @has_more = false
    @cursor = nil
  end

  # Get existing upload IDs to show "Already Attached" state
  existing_upload_ids = @context_object.try(:upload_ids) || []

  respond_to do |format|
    format.turbo_stream do
      if is_append
        # Appending more results (infinite scroll)
        # Use multiple streams: append items to grid, replace trigger with new trigger
        render 'attachable/search_library_append',
               locals: {
                 publications: @publications,
                 existing_upload_ids: existing_upload_ids,
                 context_object_id: context_object_id,
                 context_class_name: params[:context_class],
                 controller_path: controller_name,
                 cursor: @cursor,
                 has_more: @has_more,
                 search_term: params[:term]
               }
      else
        # Fresh search - replace entire frame
        render turbo_stream: turbo_stream.update(
          "publication-picker-frame-#{context_object_id}",
          Crm::PublicationPickerComponent.new(
            offcanvas_id: "publication-picker-#{context_object_id}",
            context_object: @context_object,
            publications: @publications,
            existing_upload_ids: existing_upload_ids,
            body_only: true,
            search_term: params[:term],
            cursor: @cursor,
            has_more: @has_more
          )
        )
      end
    end
    format.html do
      if is_append
        # Turbo-frame lazy loading with turbo_stream response
        # Must set content_type so Turbo processes the stream actions
        render 'attachable/search_library_append',
               formats: [:turbo_stream],
               content_type: 'text/vnd.turbo-stream.html',
               locals: {
                 publications: @publications,
                 existing_upload_ids: existing_upload_ids,
                 context_object_id: context_object_id,
                 context_class_name: params[:context_class],
                 controller_path: controller_name,
                 cursor: @cursor,
                 has_more: @has_more,
                 search_term: params[:term]
               }
      elsif request.headers['Turbo-Frame'].present?
        render 'attachable/search_library', layout: false
      else
        render 'attachable/search_library'
      end
    end
  end
end