studio-engine 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,8 +12,8 @@
12
12
  #
13
13
  # What a section looks like:
14
14
  #
15
- # { key: :avatar, title: "Profile photo", partial: "studio/profiles/avatar_section",
16
- # locals: { ... }, requires: :avatar, admin: false }
15
+ # { key: :name, title: "Name", page: :edit, partial: "studio/profiles/name_fields",
16
+ # locals: { ... }, requires: :first_name, admin: false }
17
17
  #
18
18
  # key — stable identifier. A host removes or replaces a default by key,
19
19
  # so reordering the defaults never breaks a host's override.
@@ -48,27 +48,44 @@
48
48
  # Studio.first_name_outstanding? already guards against with respond_to?.
49
49
  #
50
50
  # So a row DECLARES what it needs and is dropped when the host cannot serve it.
51
- # mcritchie-industries gets a working profile page with an avatar row and no name
52
- # row until it installs the standard-profile-columns migration, at which point
53
- # the row appears with no code change. Silence, not a 500.
51
+ # mcritchie-industries gets a working edit page with an email field and no name
52
+ # or birthday fields until it installs the standard-profile-columns migration, at
53
+ # which point they appear with no code change. Silence, not a 500.
54
54
  module Studio
55
55
  module ProfileSections
56
- # The page every consumer gets for free. Iteration one is deliberately two
57
- # rows: the picture and the name. Everything else the account-standardization
58
- # program lifts (email change, identities, preferences) arrives as further
59
- # defaults, and a host that has declared its own list keeps working because
60
- # it composes against `Studio.default_profile_sections` rather than a literal.
56
+ # Which page a row belongs to. `:show` is the read page (/profile) — things
57
+ # you look at and occasionally act on; `:edit` is the form (/profile/edit).
58
+ PAGES = %i[show edit].freeze
59
+
60
+ # The rows every consumer gets for free.
61
+ #
62
+ # THE AVATAR IS NOT HERE, and that is a change rather than an omission: it
63
+ # moved into the identity header both pages render, where it sits with the
64
+ # display name and the address. It stopped being a row when it stopped
65
+ # looking like one.
61
66
  DEFAULTS = [
62
- { key: :avatar, title: "Profile photo",
63
- partial: "studio/profiles/avatar_section", requires: :avatar, modals: true },
64
- { key: :first_name, title: "Your name",
65
- partial: "studio/profiles/first_name_section", requires: :first_name },
66
- # Gated on the app OFFERING Google, not merely on having the columns
67
- # the same question app/views/sessions/new.html.erb:79 already asks before
68
- # drawing this identical button on the login page.
69
- { key: :google, title: "Google account",
67
+ # --- the read page ------------------------------------------------------
68
+ # Google is an identity you CONNECT, not a field you type, which is why it
69
+ # reads rather than edits. Gated on the app OFFERING Google, not merely on
70
+ # having the columns — the same question app/views/sessions/new.html.erb
71
+ # already asks before drawing this identical button on the login page.
72
+ { key: :google, title: "Google account", page: :show,
70
73
  partial: "studio/profiles/google_section", requires: %i[provider uid],
71
- if: -> { Studio.auth_method?(:google) } }
74
+ if: -> { Studio.auth_method?(:google) } },
75
+
76
+ # --- the edit page ------------------------------------------------------
77
+ # These are FIELDS in one form with one Save, so they carry no buttons of
78
+ # their own. Email included: it needed a separate action only while it was
79
+ # out-of-band, and now that it applies directly there is no reason for a
80
+ # second mechanism on the same page. Its side effects — the Google lock,
81
+ # notifying the old address, invalidating other sessions — are the server's
82
+ # business, not the form's.
83
+ { key: :name, title: "Name", page: :edit,
84
+ partial: "studio/profiles/name_fields", requires: :first_name },
85
+ { key: :email, title: "Email", page: :edit,
86
+ partial: "studio/profiles/email_fields", requires: :email },
87
+ { key: :birthday, title: "Birthday", page: :edit,
88
+ partial: "studio/profiles/birthday_fields", requires: %i[birth_day birth_month birth_year] }
72
89
  ].freeze
73
90
 
74
91
  module_function
@@ -86,7 +103,9 @@ module Studio
86
103
  # `nil` meaning "the defaults" rather than "no sections" is the load-bearing
87
104
  # choice here: it is what makes a brand-new app's profile page work with an
88
105
  # empty initializer, which is the entire point of standardizing this.
89
- def resolve(declared, view)
106
+ # `page:` selects a subset. nil means every page, which is what the pre-split
107
+ # callers passed and what a host asking "all of them" wants.
108
+ def resolve(declared, view, page: nil)
90
109
  sections = declared.respond_to?(:call) ? declared.call(view) : declared
91
110
  sections = defaults if sections.nil?
92
111
 
@@ -95,6 +114,7 @@ module Studio
95
114
 
96
115
  Array(sections)
97
116
  .map { |section| symbolize(section) }
117
+ .reject { |section| page && page_of(section) != page.to_sym }
98
118
  .reject { |section| section[:admin] && !admin }
99
119
  .select { |section| enabled?(section[:if], view) }
100
120
  .select { |section| served_by?(user, section[:requires]) }
@@ -116,9 +136,21 @@ module Studio
116
136
  # that silently does nothing, in the same permissive direction as the bug
117
137
  # this whole `if:` key was added to fix. A host would have had no signal.
118
138
  if condition.is_a?(Symbol) || condition.is_a?(String)
119
- return false unless view.respond_to?(condition)
139
+ unless view.respond_to?(condition)
140
+ # SILENT would repeat the bug this key was added to fix. The old
141
+ # coercion gave the host no signal; dropping the row without one only
142
+ # moves the silence somewhere safer. A typo'd gate now says so.
143
+ warn_gate("profile_sections: `if: #{condition.inspect}` names a method the view " \
144
+ "does not answer — the row was dropped. Check the spelling.")
145
+ return false
146
+ end
120
147
 
121
- return !!view.public_send(condition)
148
+ # Arity-aware, matching the lambda branch below: a predicate written as
149
+ # `def visible?(view)` is as natural as one written without an argument,
150
+ # and calling it wrong raises ArgumentError — which surfaces as a 500 on
151
+ # /profile rather than as a dropped row.
152
+ method = view.method(condition)
153
+ return !!(method.arity.zero? ? view.public_send(condition) : view.public_send(condition, view))
122
154
  end
123
155
 
124
156
  return !!condition unless condition.respond_to?(:call)
@@ -129,6 +161,12 @@ module Studio
129
161
  # Can this host's user model serve the row? No requirement means yes — a row
130
162
  # that reads nothing off the user (a static explainer, a link out) is always
131
163
  # served. A nil user means we are rendering for nobody, and nothing is served.
164
+ # A row that never says defaults to :edit — someone adding a row is usually
165
+ # adding a field, and the read page is a deliberate, curated surface.
166
+ def page_of(section)
167
+ (section[:page] || :edit).to_sym
168
+ end
169
+
132
170
  def served_by?(user, requires)
133
171
  needed = Array(requires).compact
134
172
  return true if needed.empty?
@@ -137,6 +175,15 @@ module Studio
137
175
  needed.all? { |attribute| user.respond_to?(attribute) }
138
176
  end
139
177
 
178
+ # Pure Ruby: this file loads without Rails, so it cannot assume a logger.
179
+ def warn_gate(message)
180
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
181
+ Rails.logger.warn(message)
182
+ else
183
+ Kernel.warn(message)
184
+ end
185
+ end
186
+
140
187
  def symbolize(hash)
141
188
  out = hash.to_h.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
142
189
 
@@ -15,6 +15,11 @@
15
15
  # Sections flagged admin: true resolve only for admin? viewers, so the
16
16
  # trigger and panel stay invisible to everyone else even when the host
17
17
  # declares nothing but admin links.
18
+ #
19
+ # THE ENGINE PREPENDS ONE SECTION OF ITS OWN — "You", linking to /profile. The
20
+ # engine ships that page, so it ships the way in rather than asking five apps to
21
+ # declare the same entry and watch them drift. See `standard` below for the two
22
+ # gates it carries.
18
23
  module Studio
19
24
  module SidebarSections
20
25
  module_function
@@ -22,9 +27,48 @@ module Studio
22
27
  def resolve(declared, view)
23
28
  sections = declared.respond_to?(:call) ? declared.call(view) : declared
24
29
  admin = view.respond_to?(:admin?) && view.admin?
25
- Array(sections).map { |section| symbolize(section) }
26
- .reject { |section| section[:admin] && !admin }
27
- .map { |section| section.merge(links: Array(section[:links]).map { |link| symbolize(link) }) }
30
+ (standard(view) + Array(sections))
31
+ .map { |section| symbolize(section) }
32
+ .reject { |section| section[:admin] && !admin }
33
+ .map { |section| section.merge(links: Array(section[:links]).map { |link| symbolize(link) }) }
34
+ end
35
+
36
+ # What the ENGINE puts in every app's sidebar, ahead of whatever the host
37
+ # declared. The engine ships /profile, so it ships the way in — otherwise
38
+ # every consumer writes the same entry and they drift in wording and emoji.
39
+ #
40
+ # PREPENDED, not appended: it is the viewer's own account, the thing nearest
41
+ # to them, and the operator asked for it at the top.
42
+ #
43
+ # TWO GATES, and both are load-bearing:
44
+ #
45
+ # * the route must be drawn. An app that set draw_profile_routes = false
46
+ # would otherwise be handed a menu item pointing at a route that does not
47
+ # exist — a 404 from its own navigation.
48
+ # * the viewer must be signed in. /profile requires authentication, so
49
+ # offering it to a signed-out visitor bounces them to the login page from
50
+ # something that looked like navigation. Same shape as the `admin:` rule
51
+ # below, which already drops sections a viewer may not see.
52
+ #
53
+ # A view that answers neither predicate (a bare context in a unit test) gets
54
+ # nothing, which is the safe direction: a missing link is visible and
55
+ # fixable, a link to nowhere is the bug.
56
+ def standard(view)
57
+ return [] unless profile_link?(view)
58
+
59
+ [{ title: "You", links: [
60
+ { label: "Profile", href: view.profile_path, emoji: "👤",
61
+ desc: "Your name, email and photo" }
62
+ ] }]
63
+ end
64
+
65
+ def profile_link?(view)
66
+ return false unless defined?(Studio) && Studio.respond_to?(:draw_profile_routes)
67
+ return false unless Studio.draw_profile_routes
68
+ return false unless view.respond_to?(:profile_path)
69
+ return false unless view.respond_to?(:logged_in?) && view.logged_in?
70
+
71
+ true
28
72
  end
29
73
 
30
74
  def symbolize(hash)
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.48.0"
2
+ VERSION = "0.50.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -613,8 +613,8 @@ module Studio
613
613
  # the view, keys symbolize, admin-only rows drop for non-admin viewers, and
614
614
  # rows this host's user model cannot serve drop entirely. `nil` config resolves
615
615
  # to the standard page.
616
- def self.profile_sections_for(view)
617
- ProfileSections.resolve(profile_sections, view)
616
+ def self.profile_sections_for(view, page: nil)
617
+ ProfileSections.resolve(profile_sections, view, page: page)
618
618
  end
619
619
 
620
620
  def self.env_truthy?(value)
@@ -683,6 +683,7 @@ module Studio
683
683
  # both would delete someone's photo every time they edited their name.
684
684
  if Studio.draw_profile_routes
685
685
  get "profile", to: "studio/profiles#show", as: :profile
686
+ get "profile/edit", to: "studio/profiles#edit", as: :edit_profile
686
687
  patch "profile", to: "studio/profiles#update"
687
688
  patch "profile/avatar", to: "studio/profiles#avatar", as: :profile_avatar
688
689
  # DELETE, because unlinking removes an identity. Linking is not drawn
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.48.0
4
+ version: 0.50.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -216,6 +216,7 @@ files:
216
216
  - app/jobs/studio/email_delivery_job.rb
217
217
  - app/mailers/application_mailer.rb
218
218
  - app/mailers/studio/newsletter_mailer.rb
219
+ - app/mailers/studio/profile_mailer.rb
219
220
  - app/mailers/user_mailer.rb
220
221
  - app/models/concerns/sluggable.rb
221
222
  - app/models/concerns/studio/board/rankable.rb
@@ -335,9 +336,21 @@ files:
335
336
  - app/views/studio/models/show.html.erb
336
337
  - app/views/studio/newsletter_mailer/subscribed.html.erb
337
338
  - app/views/studio/newsletter_mailer/subscribed.text.erb
338
- - app/views/studio/profiles/_avatar_section.html.erb
339
- - app/views/studio/profiles/_first_name_section.html.erb
339
+ - app/views/studio/profile_mailer/email_change_notification.html.erb
340
+ - app/views/studio/profile_mailer/email_change_notification.text.erb
341
+ - app/views/studio/profiles/_birthday_fields.html.erb
342
+ - app/views/studio/profiles/_editable_identity.html.erb
343
+ - app/views/studio/profiles/_email_fields.html.erb
344
+ - app/views/studio/profiles/_form_script.html.erb
340
345
  - app/views/studio/profiles/_google_section.html.erb
346
+ - app/views/studio/profiles/_identity.html.erb
347
+ - app/views/studio/profiles/_identity_body.html.erb
348
+ - app/views/studio/profiles/_identity_mini.html.erb
349
+ - app/views/studio/profiles/_identity_styles.html.erb
350
+ - app/views/studio/profiles/_name_fields.html.erb
351
+ - app/views/studio/profiles/_pencil_icon.html.erb
352
+ - app/views/studio/profiles/_save_bar.html.erb
353
+ - app/views/studio/profiles/edit.html.erb
341
354
  - app/views/studio/profiles/show.html.erb
342
355
  - app/views/style/_modal_specimen.html.erb
343
356
  - app/views/style/_modals.html.erb
@@ -1,93 +0,0 @@
1
- <%# Profile photo row — lifted from turf-monster's /account, which is the
2
- interaction the operator wants standardized: click the avatar, hover reveals
3
- an "Update" cap, pick a file, crop it square, and it saves immediately. No
4
- Save button, no visible file field.
5
-
6
- Locals: user (required).
7
-
8
- HOW IT HANGS TOGETHER
9
- imageUploadHost() the engine's crop-then-immediate-save x-data factory
10
- (studio/modals/_image_upload). onFileSelected() reads
11
- the picked file and opens the crop modal; the modal
12
- dispatches the cropped Blob back on
13
- 'crop-photo-confirmed'; applyCrop() drops it into the
14
- hidden form's file input and submits with a saving
15
- card.
16
- profileModals the PAGE-SCOPED Alpine store mounted by show.html.erb.
17
- Not the shared "modals" store — see below.
18
- profile_avatar_path its own endpoint, because an attachment param
19
- submitted empty PURGES the attachment.
20
-
21
- WHY THE PAGE-SCOPED STORE AND NOT `modals`. turf's copy opens on
22
- Alpine.store('modals') — the app's shared modal host — which works there
23
- because turf's layout mounts one and registers crop-photo in it. Two of the
24
- five consumers (mcritchie-industries, moms-app) render NO shared host at all,
25
- and the two that do would each have to add a crop-photo registration to their
26
- layout before this row worked. An engine page cannot ask that. show.html.erb
27
- mounts studio/modals/_scoped_host instead, so the page brings its own modals
28
- and this row behaves identically in every app. /admin/emails already does
29
- exactly this.
30
-
31
- WHY onCropConfirmed AND NOT applyCrop DIRECTLY. turf's call site binds
32
- `applyCrop($event.detail.blob)`, which predates the owner guard. Every
33
- imageUploadHost on a page hears the same window event, so a page that later
34
- grows a second uploader (a host row with its own image) would have both
35
- hosts save the same crop. onCropConfirmed() checks the owner token first and
36
- is a strict improvement at the same call cost.
37
-
38
- ON THE INLINE PIXEL SIZES. Same reason turf gives: the exact circle geometry
39
- is expressed inline rather than through w-24/h-24, because a size utility
40
- only exists if something already emitted it into the compiled bundle. The
41
- classes used here (group-hover:opacity-100, rounded-full, object-cover,
42
- absolute inset-0) already ship in other engine partials.
43
- %>
44
- <div x-data="imageUploadHost({
45
- store: 'profileModals',
46
- aspectRatio: 1,
47
- filename: 'avatar.png',
48
- saving: 'Saving photo…',
49
- toast: false
50
- })"
51
- @crop-photo-confirmed.window="onCropConfirmed($event.detail)"
52
- class="flex flex-col items-center">
53
-
54
- <%# Auto-submitted once a crop is confirmed. Hidden because the person never
55
- touches it — applyCrop() writes the cropped File onto fileInput and calls
56
- submitFormWithProgress on form. %>
57
- <%= form_with url: profile_avatar_path, method: :patch, scope: :profile,
58
- html: { multipart: true, "x-ref": "form", class: "hidden" } do %>
59
- <input type="file" name="profile[avatar]" x-ref="fileInput">
60
- <% end %>
61
-
62
- <%# The real picker. Opened by the avatar button; never shown. %>
63
- <input type="file" x-ref="filePicker" @change="onFileSelected($event)"
64
- accept="<%= Studio::ProfileImage::ALLOWED_CONTENT_TYPES.join(",") %>" class="hidden">
65
-
66
- <%# The clickable avatar. A BUTTON rather than turf's <div @click>: a div is
67
- unreachable by keyboard and invisible to a screen reader, so the only way
68
- to change your photo would be a mouse. Same visual, and it focuses. %>
69
- <button type="button"
70
- @click="$refs.filePicker.click()"
71
- aria-label="Change your profile photo"
72
- style="width: 96px; height: 96px;"
73
- class="relative rounded-full cursor-pointer group overflow-hidden mb-3 flex-shrink-0 p-0 border-0 bg-transparent">
74
- <% if user.avatar.attached? %>
75
- <%= image_tag user.avatar, class: "rounded-full object-cover",
76
- style: "width: 96px; height: 96px;", alt: user.display_name %>
77
- <% else %>
78
- <div class="rounded-full flex items-center justify-center font-bold text-white text-3xl"
79
- style="width: 96px; height: 96px; background-color: <%= user.avatar_color %>">
80
- <%= user.avatar_initials %>
81
- </div>
82
- <% end %>
83
-
84
- <%# The hover cap. group-hover reveals it on pointer; focus-within is the
85
- keyboard equivalent, so a tabbing user sees the same affordance. %>
86
- <span class="absolute inset-0 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity duration-200"
87
- style="background: rgba(0,0,0,0.5)">
88
- <span class="text-white text-xs font-bold">Update</span>
89
- </span>
90
- </button>
91
-
92
- <div class="text-xl font-bold text-heading"><%= user.display_name %></div>
93
- </div>
@@ -1,26 +0,0 @@
1
- <%# First-name row.
2
-
3
- Locals: user (required).
4
-
5
- This row only renders when the host's user model actually answers
6
- `first_name` — Studio::ProfileSections drops it otherwise, so an app that has
7
- not installed the standard profile columns gets a page without this row
8
- rather than a NoMethodError on every visit. Nothing here needs to re-check.
9
-
10
- The same column is written by the onboarding step
11
- (Studio::OnboardingController#first_name); this is the surface for changing it
12
- later.
13
- %>
14
- <%= form_with url: profile_path, method: :patch, scope: :profile,
15
- data: { turbo: false }, class: "flex flex-wrap items-end gap-3" do |form| %>
16
- <div class="flex-1 min-w-0" style="min-width: 12rem;">
17
- <label class="block text-sm text-secondary mb-2 font-medium" for="profile_first_name">First name</label>
18
- <%= form.text_field :first_name,
19
- value: user.first_name,
20
- maxlength: Studio::FIRST_NAME_MAX_LENGTH,
21
- autocomplete: "given-name",
22
- placeholder: "What should we call you?",
23
- class: "input-field" %>
24
- </div>
25
- <%= form.submit "Save", class: "btn btn-primary" %>
26
- <% end %>