Module: ArticlesHelper

Defined in:
app/helpers/articles_helper.rb

Overview

View helper: articles.

Command menus, revision badges, and state-event options for the article
family (ArticleFaq, ArticleTechnical, ArticleTraining, ArticleProcedure)
and their ArticleRevision workflow.

Constant Summary collapse

ARTICLE_ROUTE_NAMES =

Maps article controller names to their singular route helper prefix.

{
  'article_technicals' => :article_technical,
  'article_faqs' => :article_faq,
  'article_trainings' => :article_training,
  'article_procedures' => :article_procedure
}.freeze
ARTICLE_REVISION_ALLOWED_TAGS =

HTML tags allowed when rendering revision rich content; extends the
Rails safe list with table markup used in article bodies.

(
  Rails::HTML5::SafeListSanitizer.allowed_tags + %w[table thead tbody tfoot tr th td]
).freeze
ARTICLE_REVISION_ALLOWED_ATTRIBUTES =

HTML attributes allowed when rendering revision rich content; extends
the Rails safe list with table cell attributes.

(
  Rails::HTML5::SafeListSanitizer.allowed_attributes + %w[colspan rowspan align]
).freeze

Instance Method Summary collapse

Instance Method Details

#article_command_options(article) ⇒ Array<ActiveSupport::SafeBuffer>

Builds the action menu links for an article (edit, revision commands,
archive, PDF, email, delete), gated by the current user's abilities.

Parameters:

  • article (Article)

    article whose command menu is being rendered

Returns:

  • (Array<ActiveSupport::SafeBuffer>)

    command links



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'app/helpers/articles_helper.rb', line 39

def article_command_options(article)
  links = []
  route_name = ARTICLE_ROUTE_NAMES.fetch(params[:controller])
  links << link_to("Edit", public_send(:"edit_#{route_name}_path", article)) if can?(:update, article)
  links.concat(
    article_revision_command_options(
      article,
      allow_proposal: !approved_technical_article?(article)
    )
  )
  if article.is_a?(ArticleTechnical) &&
     article.state.in?(ArticleTechnical::APPROVED_STATES) &&
     can?(:approve, article)
    links << link_to(
      'Archive',
      archive_article_technical_path(article),
      data: {
        turbo_method: :post,
        turbo_confirm: 'Archive this approved Technical Article without a replacement?'
      }
    )
  end
  links << link_to("Convert to PDF", public_send(:"#{route_name}_path", article, format: :pdf))
  links << link_to("Send by Email", public_send(:"send_by_email_#{route_name}_path", article))
  if can?(:destroy, article)
    links << link_to("Delete", public_send(:"#{route_name}_path", article),
                data: { turbo_confirm: "Are you sure?", turbo_method: :delete })
  end
  links
end

#article_conversion_options(article) ⇒ Array<ActiveSupport::SafeBuffer>

Builds "Convert to …" links for changing an article's STI type.

Parameters:

  • article (Article)

    article being converted

Returns:

  • (Array<ActiveSupport::SafeBuffer>)

    conversion links, empty when the user cannot update



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'app/helpers/articles_helper.rb', line 118

def article_conversion_options(article)
  return [] unless can?(:update, article)

  conversions = { 'ArticleFaq' => 'Faq', 'ArticleTechnical' => 'Technical', 'ArticleTraining' => 'Training', 'ArticleProcedure' => 'Procedure' }
  links = []
  route_name = ARTICLE_ROUTE_NAMES.fetch(params[:controller])
  conversions.each do |sti_type, label|
    next if article.type == sti_type

    links << link_to(
      "Convert to #{label}",
      public_send(:"convert_type_#{route_name}_path", article, article_type_id: sti_type),
      data: { turbo_method: :post, turbo_confirm: 'Are you sure you want to convert this article?' }
    )
  end
  links
end

#article_events_for_select(article) ⇒ Array<Array(String, Symbol, nil)>

State-event select options for an article, filtered by type-specific
transition rules and the current user's approval ability.

Parameters:

  • article (Article)

    article whose state events are offered

Returns:

  • (Array<Array(String, Symbol, nil)>)

    label/event pairs for the select



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'app/helpers/articles_helper.rb', line 380

def article_events_for_select(article)
  options = article.events_for_select
  if article.is_a?(ArticleProcedure)
    return options.filter_map do |label, event|
      next [label, event] if event.blank?
      next unless event.to_s.in?(ArticleProceduresController::SUPPORTED_STATE_EVENTS)
      next unless can?(:approve, article)

      next ['Make Internal', event] if event.to_s == 'not_for_public'

      [label, event]
    end
  end
  return options unless article.is_a?(ArticleTechnical)
  return options if can?(:approve, article)

  options.reject { |_label, event| event.to_s.in?(ArticleTechnicalsController::APPROVAL_EVENTS) }
end

#article_revision_changed_article_data_fields(article, revision) ⇒ Array<Symbol>

Review data fields whose values differ between the article and the
revision, limited to fields the revision actually captured.

Parameters:

Returns:

  • (Array<Symbol>)

    changed field names from ArticleRevision::REVIEW_DATA_FIELDS



318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'app/helpers/articles_helper.rb', line 318

def article_revision_changed_article_data_fields(article, revision)
  return [] unless revision.article_data_captured?

  current_article_data = article.revision_article_data
  fields = ArticleRevision::REVIEW_DATA_FIELDS.reject do |field|
    field == ArticleRevision::CONTENT_LINK_DATA_FIELD && !revision.content_links_captured?
  end
  fields.reject do |field|
    proposed_value = if field == ArticleRevision::CONTENT_LINK_DATA_FIELD
                       revision.content_link_data
                     else
                       revision.article_data[field.to_s]
                     end
    current_article_data[field.to_s] == proposed_value
  end
end

#article_revision_changed_fields(article, revision) ⇒ Array<Symbol>

Content fields whose values differ between the article and the revision.

Parameters:

Returns:

  • (Array<Symbol>)

    changed field names from ArticleRevision::CONTENT_FIELDS



282
283
284
285
286
# File 'app/helpers/articles_helper.rb', line 282

def article_revision_changed_fields(article, revision)
  ArticleRevision::CONTENT_FIELDS.reject do |field|
    article.public_send(field).to_s == revision.public_send(field).to_s
  end
end

#article_revision_command_options(article, allow_proposal: true) ⇒ Array<ActiveSupport::SafeBuffer>

Builds shared history and open-proposal commands for an Article.

Parameters:

  • article (Article)

    article whose revisions are being managed

  • allow_proposal (Boolean) (defaults to: true)

    whether to show the standalone proposal action

Returns:

  • (Array<ActiveSupport::SafeBuffer>)

    revision command links



75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'app/helpers/articles_helper.rb', line 75

def article_revision_command_options(article, allow_proposal: true)
  return [] unless article.uses_revisions?

  links = [link_to('Revision History', article_revision_history_path(article))]
  return links unless can?(:update, article)

  if (revision = article.open_revision)
    links << link_to("Open Revision ##{revision.revision_number}", article_revision_path(article, revision))
  elsif article.revision_proposable? && allow_proposal
    links << link_to('Propose Content Revision', new_article_revision_path(article))
  end
  links
end

#article_revision_comparison_label(revision) ⇒ String

Label for a revision column in a side-by-side comparison view.

Parameters:

Returns:

  • (String)

    comparison label such as "Proposed Revision"



266
267
268
269
270
271
272
273
274
275
# File 'app/helpers/articles_helper.rb', line 266

def article_revision_comparison_label(revision)
  case revision.review_state
  when 'draft', 'pending_review' then 'Proposed Revision'
  when 'rejected' then 'Rejected Proposal'
  when 'approved', 'recorded'
    revision.current? ? 'Applied Revision' : 'Historical Revision'
  else
    'Saved Revision'
  end
end

#article_revision_context_message(article, revision) ⇒ String

Explanatory banner telling the user they are viewing a revision rather
than the live article content.

Parameters:

Returns:

  • (String)

    context message naming the article and revision state



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'app/helpers/articles_helper.rb', line 243

def article_revision_context_message(article, revision)
  case revision.review_state
  when 'draft'
    "You are viewing a draft proposal, not the current Article. Article ##{article.id} remains unchanged unless this proposal is submitted and approved."
  when 'pending_review'
    "You are viewing a proposal awaiting review, not the current Article. Article ##{article.id} remains unchanged until this proposal is approved."
  when 'rejected'
    "You are viewing a rejected proposal, not the current Article. It did not change Article ##{article.id}."
  when 'approved', 'recorded'
    if revision.current?
      "This revision was applied to Article ##{article.id}. The Article page remains the authoritative source for its current content."
    else
      "You are viewing a historical snapshot, not the current Article. Article ##{article.id} may have changed since this revision."
    end
  else
    "You are viewing a saved revision of Article ##{article.id}. The Article page is the authoritative source for current content."
  end
end

#article_revision_heading(revision) ⇒ String

Heading for a revision detail page, combining the review-state label
with the revision number.

Parameters:

Returns:

  • (String)

    heading such as "Draft Proposal — Revision #3"



203
204
205
206
207
208
209
210
211
212
213
214
215
# File 'app/helpers/articles_helper.rb', line 203

def article_revision_heading(revision)
  label = case revision.review_state
          when 'draft' then 'Draft Proposal'
          when 'pending_review' then 'Proposal Awaiting Review'
          when 'rejected' then 'Rejected Proposal'
          when 'approved', 'recorded'
            revision.current? ? 'Applied Revision' : 'Historical Revision'
          else
            revision.review_state.humanize
          end

  "#{label} — Revision ##{revision.revision_number}"
end

#article_revision_history_path(article) ⇒ String

Keeps revision history on the parent Article page instead of sending the
user to a second index page.

Parameters:

  • article (Article)

    article whose revision anchor is linked

Returns:

  • (String)

    polymorphic article path with a revisions anchor



94
95
96
97
# File 'app/helpers/articles_helper.rb', line 94

def article_revision_history_path(article)
  anchor = article.is_a?(Post) ? 'revisions' : 'article-revisions'
  polymorphic_path(article, anchor:)
end

#article_revision_pages_value(pages) ⇒ ActiveSupport::SafeBuffer

Renders a revision's multi-page content as stacked sections.

Parameters:

  • pages (Array<ArticlePage, Hash>)

    page records captured on the revision

Returns:

  • (ActiveSupport::SafeBuffer)

    rendered pages, or the empty-value placeholder



292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'app/helpers/articles_helper.rb', line 292

def article_revision_pages_value(pages)
  return article_revision_empty_value if pages.blank?

  rendered_pages = pages.map.with_index do |page, index|
    attributes = page.to_h.stringify_keys
    heading = attributes['headline'].presence || "Page #{index + 1}"

    tag.section(class: 'border-bottom pb-3 mb-3') do
      safe_join(
        [
          tag.h6(heading, class: 'mb-2'),
          article_revision_rich_content(attributes['content'])
        ]
      )
    end
  end

  tag.div(safe_join(rendered_pages), class: 'article-revision-page-content')
end

#article_revision_relationship_label(revision, current: nil) ⇒ String

Short label describing how a revision relates to the current article.

Parameters:

Returns:

  • (String)

    relationship label such as "Applied to current Article"



223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'app/helpers/articles_helper.rb', line 223

def article_revision_relationship_label(revision, current: nil)
  current = revision.current? if current.nil?

  case revision.review_state
  when 'draft' then 'Draft proposal — not applied'
  when 'pending_review' then 'Awaiting review — not applied'
  when 'rejected' then 'Rejected — not applied'
  when 'approved', 'recorded'
    current ? 'Applied to current Article' : 'Historical snapshot'
  else
    revision.review_state.humanize
  end
end

#article_revision_state_badge(revision, current: nil) ⇒ ActiveSupport::SafeBuffer

Bootstrap badge for a revision's review state, distinguishing the
currently applied revision from historical ones.

Parameters:

Returns:

  • (ActiveSupport::SafeBuffer)

    badge span with the state label



185
186
187
188
189
190
191
192
193
194
195
196
# File 'app/helpers/articles_helper.rb', line 185

def article_revision_state_badge(revision, current: nil)
  current = revision.current? if current.nil?
  label, css = {
    'recorded' => [current ? 'Current' : 'Historical', current ? 'success' : 'secondary'],
    'draft' => %w[Draft secondary],
    'pending_review' => ['Pending Review', 'warning text-dark'],
    'approved' => [current ? 'Current' : 'Approved', current ? 'success' : 'primary'],
    'rejected' => %w[Rejected danger]
  }.fetch(revision.review_state, [revision.review_state.humanize, 'secondary'])

  tag.span(label, class: "badge bg-#{css}")
end

#article_revision_subject_label(article) ⇒ String

Human label for the article's subject field, varying by STI type.

Parameters:

  • article (Article)

    article whose subject label is rendered

Returns:

  • (String)

    field label such as "Article Title" or "Question"



103
104
105
106
107
108
109
110
111
112
# File 'app/helpers/articles_helper.rb', line 103

def article_revision_subject_label(article)
  case article
  when Post then 'Article Heading (H1)'
  when ArticleFaq then 'Question'
  when ArticleTechnical then 'Article Title'
  when ArticleTraining then 'Training Subject'
  when ArticleProcedure then 'Procedure Title'
  else 'Subject'
  end
end

#article_state_label(article) ⇒ ActiveSupport::SafeBuffer

Bootstrap badge reflecting the article's workflow state.

Parameters:

  • article (Article)

    article whose state badge is rendered

Returns:

  • (ActiveSupport::SafeBuffer)

    badge span with the humanized state name



166
167
168
169
170
171
172
173
174
175
176
# File 'app/helpers/articles_helper.rb', line 166

def article_state_label(article)
  css_state = {
    draft: 'secondary',
    pending_review: 'info text-dark',
    scheduled: 'info',
    published: 'success',
    internal: 'primary',
    archived: 'warning text-dark'
  }[article.state.to_sym] || 'default'
  (:span, article.human_state_name, class: "badge bg-#{css_state}")
end

#polymorphic_article_path(article) ⇒ String?

Show path for an article, or nil when the article is missing or has no
route.

Parameters:

  • article (Article, nil)

    article to link to

Returns:

  • (String, nil)

    show path, or nil when unroutable



154
155
156
157
158
159
160
# File 'app/helpers/articles_helper.rb', line 154

def polymorphic_article_path(article)
  return nil unless article.present? && article.respond_to?(:to_model)

  polymorphic_path(article)
rescue ActionController::UrlGenerationError, NoMethodError
  nil
end

#safe_edit_path(record) ⇒ String?

Edit path for a record, or nil when the record is missing or has no
edit route.

Parameters:

Returns:

  • (String, nil)

    edit path, or nil when unroutable



141
142
143
144
145
146
147
# File 'app/helpers/articles_helper.rb', line 141

def safe_edit_path(record)
  return nil unless record.present? && record.respond_to?(:to_model)

  polymorphic_path([:edit, record])
rescue ActionController::UrlGenerationError, NoMethodError
  nil
end

#setup_training_article(article) ⇒ ArticleTraining

Ensures a training article has at least one content page before the
form renders.

Parameters:

Returns:

  • (ArticleTraining)

    the same article, with a built first page when blank



404
405
406
407
408
409
# File 'app/helpers/articles_helper.rb', line 404

def setup_training_article(article)
  # Create one page of content
  article.tap do |article|
    article.article_pages.build(position: 1) if article.article_pages.blank?
  end
end

#time_choices_for_articleArray<Array(String, Integer, nil)>

Options for a time-of-day select in 15-minute increments.

Returns:

  • (Array<Array(String, Integer, nil)>)

    label/value pairs from 15 to 240 minutes



30
31
32
# File 'app/helpers/articles_helper.rb', line 30

def time_choices_for_article
  [['', nil]] + (15..240).step(15).to_a.map { |t| ["#{t} min", t] }
end