• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MushroomObserver / mushroom-observer / 20596684700

30 Dec 2025 12:36PM UTC coverage: 95.261% (+0.008%) from 95.253%
20596684700

Pull #3659

github

web-flow
Merge pull request #3661 from MushroomObserver/nimmo-lightbox-caption-sync

Consolidate lightbox caption sync into lightgallery controller
Pull Request #3659: Refactor reviewed state logic and add test coverage

17 of 18 new or added lines in 4 files covered. (94.44%)

7 existing lines in 3 files now uncovered.

31440 of 33004 relevant lines covered (95.26%)

713.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.03
/app/components/base_image.rb
1
# frozen_string_literal: true
2

3
# Abstract base class for image components.
4
#
5
# This component replaces the ImagePresenter class, handling all image
6
# presentation logic internally through Literal properties and methods.
7
#
8
# Provides shared functionality for rendering images with:
9
# - Lazy loading with aspect ratio preservation
10
# - Lightbox support
11
# - Vote sections
12
# - Stretched links (GET, POST, PUT, PATCH, DELETE)
13
# - Original filename display
14
# - Image sizing calculations
15
#
16
# Subclasses should implement view_template to define their specific rendering.
17
class Components::BaseImage < Components::Base
1✔
18
  # Type definitions
19
  Size = _Union(*::Image::ALL_SIZES)
1✔
20
  Verb = _Union(:get, :post, :put, :patch, :delete)
1✔
21
  Fit = _Union(:cover, :contain)
1✔
22

23
  # Core properties
24
  prop :user, _Nilable(User)
1✔
25
  prop :image, _Nilable(::Image)
1✔
26

27
  # Allow for explicitly passed img_id in uploads
28
  # String IDs are needed for form components (e.g., FormCarouselItem)
29
  # that handle newly uploaded images with provisional IDs before persistence.
30
  prop :img_id, _Union(Integer, String, nil) do |value|
1✔
31
    case value
1,539✔
32
    when Integer then value.to_s
1✔
33
    when String then value
2✔
34
    end
35
  end
36

37
  # Display options
38
  prop :size, Size, default: :small
1✔
39
  prop :votes, _Boolean, default: true
1✔
40
  prop :original, _Boolean, default: false
1✔
41
  prop :is_set, _Boolean, default: true
1✔
42
  prop :full_width, _Boolean, default: false
1✔
43
  prop :notes, String, default: ""
1✔
44
  prop :extra_classes, String, default: ""
1✔
45
  prop :id_prefix, String, default: "interactive_image"
1✔
46

47
  # Link configuration
48
  prop :image_link, _Nilable(_Union(String, Hash)), default: nil
1✔
49
  prop :link_method, Verb, default: :get
1✔
50

51
  # Image fitting and data
52
  prop :fit, Fit, default: :cover
1✔
53
  prop :data, Hash, default: -> { {} }
1,540✔
54
  prop :data_sizes, Hash, default: -> { {} }
1,540✔
55

56
  # Lightbox and observation context
57
  prop :obs, _Union(Observation, Hash), default: -> { {} }
578✔
58
  prop :identify, _Boolean, default: false
1✔
59

60
  # Upload mode (no real image instance)
61
  prop :upload, _Boolean, default: false
1✔
62

63
  # This should be implemented by subclasses
64
  def view_template
1✔
UNCOV
65
    raise(NotImplementedError.new("Subclasses must implement view_template"))
×
66
  end
67

68
  protected
1✔
69

70
  # Extract image instance and ID from the image prop.
71
  # Allow for explicitly passed img_id in uploads
72
  def extract_image_and_id
1✔
73
    if @image.is_a?(::Image)
1,538✔
74
      @img_id ||= @image.id
1,536✔
75
      [@image, @img_id]
1,536✔
76
    else
77
      @img_id ||= @image
2✔
78
      [nil, @img_id]
2✔
79
    end
80
  end
81

82
  # Build all rendering data needed for image display
83
  def build_render_data(img_instance, img_id)
1✔
84
    img_urls = fetch_image_urls(img_instance, img_id)
1,538✔
85
    sizing = calculate_sizing(img_instance)
1,538✔
86

87
    {
88
      img_src: img_urls[@size] || "",
1,538✔
89
      img_class: build_image_classes,
90
      img_data: build_data_attributes(img_urls),
91
      img_id: img_id,
92
      html_id: "#{@id_prefix}_#{img_id}",
93
      proportion: sizing[:proportion],
94
      width: sizing[:width],
95
      image_link: normalize_link(@image_link) || image_path(img_id),
96
      lightbox_data: build_lightbox_data(img_instance, img_id, img_urls)
97
    }
98
  end
99

100
  def fetch_image_urls(img_instance, img_id)
1✔
101
    return {} if @upload
1,538✔
102

103
    img_instance&.all_urls || ::Image.all_urls(img_id)
1,536✔
104
  end
105

106
  def build_image_classes
1✔
107
    class_names("img-fluid ab-fab object-fit-#{@fit}", @extra_classes)
1,538✔
108
  end
109

110
  def build_data_attributes(img_urls)
1✔
111
    { src: img_urls[@size] || "" }.merge(@data)
1,538✔
112
  end
113

114
  # Calculate image sizing for lazy load aspect ratio
115
  def calculate_sizing(img_instance)
1✔
116
    return { proportion: 100, width: false } unless img_instance
1,538✔
117

118
    proportion = calculate_proportion(img_instance)
1,536✔
119
    width_value = calculate_width(img_instance, proportion[:ratio])
1,536✔
120

121
    {
122
      proportion: proportion[:padding],
1,536✔
123
      width: width_value
124
    }
125
  end
126

127
  def calculate_proportion(img_instance)
1✔
128
    img_width = BigDecimal(img_instance.width || 100)
1,536✔
129
    img_height = BigDecimal(img_instance.height || 100)
1,536✔
130
    img_proportion = img_height / img_width
1,536✔
131
    img_padding = (img_proportion * 100).to_f.truncate(1)
1,536✔
132

133
    # Limit proportion 1.3:1 h/w for thumbnail
134
    img_padding = "133.33" if img_padding.to_i > 133
1,536✔
135

136
    { padding: img_padding, ratio: img_proportion }
1,536✔
137
  end
138

139
  def calculate_width(img_instance, img_proportion)
1✔
140
    return false if @full_width
1,536✔
141

142
    img_width = BigDecimal(img_instance.width || 100)
572✔
143
    img_height = BigDecimal(img_instance.height || 100)
572✔
144
    size_index = ::Image::ALL_SIZES_INDEX[@size]
572✔
145

146
    container_width = if img_width > img_height
572✔
147
                        size_index
2✔
148
                      else
149
                        size_index / img_proportion
570✔
150
                      end
151
    container_width.to_f.truncate(0)
572✔
152
  end
153

154
  # Build lightbox data hash
155
  def build_lightbox_data(img_instance, img_id, img_urls)
1✔
156
    return nil unless img_instance
1,538✔
157

158
    lb_size = @user&.image_size&.to_sym || :huge
1,536✔
159

160
    {
161
      url: img_urls[lb_size],
1,536✔
162
      id: @is_set ? "observation-set" : SecureRandom.uuid,
1,536✔
163
      image: img_instance,
164
      image_id: img_id,
165
      obs: @obs,
166
      identify: @identify
167
    }
168
  end
169

170
  # Render lightbox link button
171
  def render_lightbox_link
1✔
172
    lightbox_data = @data&.[](:lightbox_data)
1,336✔
173
    return unless lightbox_data
1,336✔
174

175
    a(
1,336✔
176
      href: lightbox_data[:url],
177
      class: "theater-btn",
178
      data: { sub_html: lightbox_caption_html(lightbox_data) }
179
    ) do
180
      i(class: "glyphicon glyphicon-fullscreen")
1,336✔
181
    end
182
  end
183

184
  # Build lightbox caption HTML using LightboxCaption component
185
  # Returns an HTML string for use in data-sub-html attribute
186
  def lightbox_caption_html(lightbox_data)
1✔
187
    return unless lightbox_data
1,336✔
188

189
    capture do
1,336✔
190
      LightboxCaption(
1,336✔
191
        user: @user,
192
        image: lightbox_data[:image],
193
        image_id: lightbox_data[:image_id],
194
        obs: lightbox_data[:obs],
195
        identify: lightbox_data[:identify]
196
      )
197
    end
198
  end
199

200
  # Render vote section for an image using ImageVoteInterface component
201
  def render_image_vote_section
1✔
202
    return unless @votes && @img_instance
1,336✔
203

204
    ImageVoteInterface(
1,147✔
205
      user: @user,
206
      image: @img_instance,
207
      votes: @votes
208
    )
209
  end
210

211
  # Render original filename if applicable
212
  def render_original_filename
1✔
213
    return unless show_original_name?
1,196✔
214

215
    div { @img_instance.original_name }
76✔
216
  end
217

218
  # Check if original filename should be shown
219
  def show_original_name?
1✔
220
    return false unless @original && @img_instance &&
1,196✔
221
                        @img_instance.original_name.present?
222

223
    permission?(@img_instance) ||
38✔
224
      (@img_instance.user &&
4✔
225
       @img_instance.user.keep_filenames == "keep_and_show")
226
  end
227

228
  # Render stretched link based on link method
229
  def render_stretched_link
1✔
230
    path = @data&.[](:image_link)
1,300✔
231

232
    case @link_method
1,300✔
233
    when :get
234
      a(href: path, class: stretched_link_classes)
1,179✔
235
    when :post, :put, :patch, :delete
236
      # These require button_to which generates a form with CSRF protection
237
      button_to(
121✔
238
        path,
239
        method: @link_method,
240
        class: stretched_link_classes,
241
        form: { data: { turbo: true } }
242
      ) { "" }
121✔
243
    end
244
  end
245

246
  # CSS classes for stretched links
247
  def stretched_link_classes
1✔
248
    "image-link ab-fab stretched-link"
1,300✔
249
  end
250

251
  # Normalize link to URL string (convert Hash to URL if needed)
252
  def normalize_link(link)
1✔
253
    return nil if link.nil?
1,538✔
254
    return link if link.is_a?(String)
1,132✔
255

256
    # Convert Hash to relative URL path using url_for
257
    url_for(link.merge(only_path: true))
1,083✔
258
  end
259
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc