Module: Studio::ProfileSections

Defined in:
lib/studio/profile_sections.rb

Constant Summary collapse

PAGES =

Which page a row belongs to. :show is the read page (/profile) — things you look at and occasionally act on; :edit is the form (/profile/edit).

%i[show edit].freeze
DEFAULTS =

The rows every consumer gets for free.

THE AVATAR IS NOT HERE, and that is a change rather than an omission: it moved into the identity header both pages render, where it sits with the display name and the address. It stopped being a row when it stopped looking like one.

[
  # --- the read page ------------------------------------------------------
  # Google is an identity you CONNECT, not a field you type, which is why it
  # reads rather than edits. Gated on the app OFFERING Google, not merely on
  # having the columns — the same question app/views/sessions/new.html.erb
  # already asks before drawing this identical button on the login page.
  { key: :google, title: "Google account", page: :show,
    partial: "studio/profiles/google_section", requires: %i[provider uid],
    if: -> { Studio.auth_method?(:google) } },

  # --- the edit page ------------------------------------------------------
  # These are FIELDS in one form with one Save, so they carry no buttons of
  # their own. Email included: it needed a separate action only while it was
  # out-of-band, and now that it applies directly there is no reason for a
  # second mechanism on the same page. Its side effects — the Google lock,
  # notifying the old address, invalidating other sessions — are the server's
  # business, not the form's.
  { key: :name, title: "Name", page: :edit,
    partial: "studio/profiles/name_fields", requires: :first_name },
  { key: :email, title: "Email", page: :edit,
    partial: "studio/profiles/email_fields", requires: :email },
  { key: :birthday, title: "Birthday", page: :edit,
    partial: "studio/profiles/birthday_fields", requires: %i[birth_day birth_month birth_year] }
].freeze

Class Method Summary collapse

Class Method Details

.defaultsObject

A fresh, mutable copy every call. Hosts compose with + and sometimes reject, and handing out the frozen literal would let one host's edit leak into the next request's page.



96
97
98
# File 'lib/studio/profile_sections.rb', line 96

def defaults
  DEFAULTS.map { |section| section.dup }
end

.enabled?(condition, view) ⇒ Boolean

The app-capability gate. No if: means always enabled. The callable takes the view when it wants one and nothing when it does not, so a host can write either -> { Studio.feature?(:x) } or ->(view) { view.admin? }.

Returns:

  • (Boolean)


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
# File 'lib/studio/profile_sections.rb', line 126

def enabled?(condition, view)
  return true if condition.nil?

  # A Symbol/String names a method on the VIEW — Rails' own
  # `before_action ..., if: :method_name` convention, and therefore the most
  # natural thing a host will write here.
  #
  # It is handled explicitly because the alternative FAILS OPEN: a Symbol
  # does not answer `call`, so the earlier `return !!condition` coerced
  # `:some_predicate` to true and rendered the row unconditionally — a gate
  # that silently does nothing, in the same permissive direction as the bug
  # this whole `if:` key was added to fix. A host would have had no signal.
  if condition.is_a?(Symbol) || condition.is_a?(String)
    unless view.respond_to?(condition)
      # SILENT would repeat the bug this key was added to fix. The old
      # coercion gave the host no signal; dropping the row without one only
      # moves the silence somewhere safer. A typo'd gate now says so.
      warn_gate("profile_sections: `if: #{condition.inspect}` names a method the view " \
                "does not answer — the row was dropped. Check the spelling.")
      return false
    end

    # Arity-aware, matching the lambda branch below: a predicate written as
    # `def visible?(view)` is as natural as one written without an argument,
    # and calling it wrong raises ArgumentError — which surfaces as a 500 on
    # /profile rather than as a dropped row.
    method = view.method(condition)
    return !!(method.arity.zero? ? view.public_send(condition) : view.public_send(condition, view))
  end

  return !!condition unless condition.respond_to?(:call)

  !!(condition.arity.zero? ? condition.call : condition.call(view))
end

.page_of(section) ⇒ Object

Can this host's user model serve the row? No requirement means yes — a row that reads nothing off the user (a static explainer, a link out) is always served. A nil user means we are rendering for nobody, and nothing is served. A row that never says defaults to :edit — someone adding a row is usually adding a field, and the read page is a deliberate, curated surface.



166
167
168
# File 'lib/studio/profile_sections.rb', line 166

def page_of(section)
  (section[:page] || :edit).to_sym
end

.resolve(declared, view, page: nil) ⇒ Object

Declared may be nil (the host has said nothing — it gets the defaults), an Array, or a callable receiving the view context.

nil meaning "the defaults" rather than "no sections" is the load-bearing choice here: it is what makes a brand-new app's profile page work with an empty initializer, which is the entire point of standardizing this. page: selects a subset. nil means every page, which is what the pre-split callers passed and what a host asking "all of them" wants.



108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/studio/profile_sections.rb', line 108

def resolve(declared, view, page: nil)
  sections = declared.respond_to?(:call) ? declared.call(view) : declared
  sections = defaults if sections.nil?

  admin = view.respond_to?(:admin?) && view.admin?
  user  = view.respond_to?(:current_user) ? view.current_user : nil

  Array(sections)
    .map { |section| symbolize(section) }
    .reject { |section| page && page_of(section) != page.to_sym }
    .reject { |section| section[:admin] && !admin }
    .select { |section| enabled?(section[:if], view) }
    .select { |section| served_by?(user, section[:requires]) }
end

.served_by?(user, requires) ⇒ Boolean

Returns:

  • (Boolean)


170
171
172
173
174
175
176
# File 'lib/studio/profile_sections.rb', line 170

def served_by?(user, requires)
  needed = Array(requires).compact
  return true if needed.empty?
  return false if user.nil?

  needed.all? { |attribute| user.respond_to?(attribute) }
end

.symbolize(hash) ⇒ Object



187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/studio/profile_sections.rb', line 187

def symbolize(hash)
  out = hash.to_h.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }

  # `key` is an IDENTIFIER, so its VALUE symbolizes too — unlike every other
  # field, whose value is data. A host composing by key writes
  # `reject { |s| s[:key] == :avatar }`, and that silently matches nothing if
  # a section declared with string keys kept `"avatar"` as a String. Removing
  # a standard row is the documented seam; it must not depend on which
  # spelling the host happened to use.
  out[:key] = out[:key].to_sym if out[:key].respond_to?(:to_sym)
  out
end

.warn_gate(message) ⇒ Object

Pure Ruby: this file loads without Rails, so it cannot assume a logger.



179
180
181
182
183
184
185
# File 'lib/studio/profile_sections.rb', line 179

def warn_gate(message)
  if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
    Rails.logger.warn(message)
  else
    Kernel.warn(message)
  end
end