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 mailing list. READ-level for the same reason Google is: it is a state
  # you look at and occasionally flip, not a field you type into.
  #
  # `modals: true` is what makes the page mount a host — leaving asks for
  # confirmation, and an account with no address on file is asked for one.
  # This is the FIRST row to declare the flag, which the registry has
  # documented since it shipped; before this the read page mounted no host at
  # all, correctly, because nothing asked.
  { key: :newsletter, title: "Newsletter", page: :show,
    partial: "studio/profiles/newsletter_section",
    requires: Studio::Newsletter::COLUMNS,
    modals: "studio/profiles/newsletter_modals" },

  # --- 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.



123
124
125
# File 'lib/studio/profile_sections.rb', line 123

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)


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

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.



193
194
195
# File 'lib/studio/profile_sections.rb', line 193

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.



135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/studio/profile_sections.rb', line 135

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)


197
198
199
200
201
202
203
# File 'lib/studio/profile_sections.rb', line 197

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



214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/studio/profile_sections.rb', line 214

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.



206
207
208
209
210
211
212
# File 'lib/studio/profile_sections.rb', line 206

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