studio-engine 0.36.0 → 0.37.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -0
- data/README.md +157 -0
- data/app/assets/images/emails/email-change-confirmation.png +0 -0
- data/app/assets/images/emails/magic-link.png +0 -0
- data/app/controllers/studio/email_images_controller.rb +38 -3
- data/app/controllers/studio/emails_controller.rb +85 -0
- data/app/mailers/user_mailer.rb +4 -1
- data/app/services/studio/email_image.rb +277 -33
- data/app/views/studio/email_images/index.html.erb +30 -1
- data/app/views/studio/emails/_row.html.erb +102 -0
- data/app/views/studio/emails/index.html.erb +116 -0
- data/app/views/studio/modals/_image_upload.html.erb +38 -7
- data/app/views/studio/modals/_scoped_host.html.erb +195 -0
- data/lib/studio/engine.rb +20 -0
- data/lib/studio/s3.rb +44 -8
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +68 -4
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc5fc673a7b9d072c93f482c90919c0f26836bd5e90194691ca30b5beb7c22d4
|
|
4
|
+
data.tar.gz: 9cbedec8a84b9619061217f90bceadbd51485e691351233bb67af6f03231b76e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '088bca3db79b1ad97d52bf2478aa08518a6139e73c700cc63d30903c1d31e4fddb391213c2061cd0711d0d5656a7115e53dd10817b094271c459543133e36ea7'
|
|
7
|
+
data.tar.gz: 96eb836f896ed9a12dd8c71a308178aa112387bc40f266323dad82db726df9698a4881ff4450c7fa7ce9f42d61cf1bcab9ca80a88940f243891e1cfe0307f3d0
|
data/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,161 @@
|
|
|
2
2
|
|
|
3
3
|
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
|
|
4
4
|
|
|
5
|
+
## 0.37.0 — 2026-08-10
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Standard transactional email primitive with a registry-backed catalog, and the
|
|
9
|
+
email preview folded into that registry.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- The crop confirm is guarded by owner, so one confirmed crop can no longer fan out
|
|
13
|
+
and overwrite every row's banner on /admin/emails.
|
|
14
|
+
|
|
5
15
|
## 0.36.0 — 2026-08-10
|
|
6
16
|
|
|
17
|
+
**Transactional emails become an engine primitive.** Every consuming app now
|
|
18
|
+
ships working, branded transactional email the moment it boots, and can grow its
|
|
19
|
+
own workflows and artwork. Additive — no host change is required to take this
|
|
20
|
+
release.
|
|
21
|
+
|
|
22
|
+
**`Studio::EmailImage` is now a registry.** It was a one-entry `VARIANTS` hash;
|
|
23
|
+
it is now a host-declared registry following the `Studio::ModelPage.register`
|
|
24
|
+
precedent. A registered email is mostly symbolic of a workflow — a key, a label,
|
|
25
|
+
a description — and its only real asset is its banner image.
|
|
26
|
+
|
|
27
|
+
- The engine **pre-registers the two every Studio app sends**, `magic_link` and
|
|
28
|
+
`email_change_confirmation`, so a host inherits both without declaring
|
|
29
|
+
anything.
|
|
30
|
+
- A host registers its own from an initializer:
|
|
31
|
+
`Studio::EmailImage.register("winnings", label: "Contest winnings")`.
|
|
32
|
+
Re-registering an inherited key **updates it in place**, keeping its page
|
|
33
|
+
position and its inherited artwork.
|
|
34
|
+
- `variants`, `label`, `known?`, `record`, `url`, and `store` keep their old
|
|
35
|
+
shapes, so `Studio::EmailImage.url(:magic_link)` — the only external caller
|
|
36
|
+
today — is untouched.
|
|
37
|
+
|
|
38
|
+
**Inheritance with per-app override.** Resolution is now two-layered:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
resolved_url(:magic_link) -> this app's ImageCache row (its own S3 bucket) app-owned
|
|
42
|
+
-> the engine's default gem asset inherited
|
|
43
|
+
-> nil bannerless
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`resolved_url`, **not** `url`. `url` deliberately keeps its pre-registry meaning —
|
|
47
|
+
this app's own image, or nil — so a caller written before the registry keeps the
|
|
48
|
+
behavior it was built against. A mailer that falls back itself
|
|
49
|
+
(`url(...) || own_banner`) must stay on `url`; switching it to `resolved_url`
|
|
50
|
+
would make that fallback dead code and replace the app's own artwork with the
|
|
51
|
+
engine default.
|
|
52
|
+
|
|
53
|
+
Defaults **ride the gem** (`app/assets/images/emails/*`), so a brand-new app with
|
|
54
|
+
an empty bucket sends good-looking email on day one with no cross-app S3
|
|
55
|
+
permission. Uploading writes to that app's own bucket and its own `image_caches`
|
|
56
|
+
row — which is exactly "the asset now belongs to this app". Placeholder artwork
|
|
57
|
+
ships for both standard emails; real artwork lands in a later release.
|
|
58
|
+
|
|
59
|
+
**New: `/admin/emails`**, modelled on the living style guide — one shared engine
|
|
60
|
+
page, admin-gated, rendering inside each host's layout.
|
|
61
|
+
|
|
62
|
+
**It is OPT-IN, and must be.** Add to `config/initializers/studio.rb`:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
config.draw_admin_emails_routes = true
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`turf-monster` already owns `/admin/emails` (its `EmailCatalog` manager:
|
|
69
|
+
`namespace :admin { get "emails", as: :emails }`) and therefore both helper
|
|
70
|
+
names, `admin_emails_path` and `admin_email_path`. Drawing the engine's page
|
|
71
|
+
unconditionally raises `ArgumentError: Invalid route name, already in use:
|
|
72
|
+
'admin_emails'` *while turf-monster's own routes are loading*, which takes down
|
|
73
|
+
its **entire** route set — every `admin_*_path` in the app goes undefined. A host
|
|
74
|
+
cannot opt out of a failure that happens before its config is read, so the
|
|
75
|
+
default is off until no consumer owns the name.
|
|
76
|
+
|
|
77
|
+
The gate covers only the **page**. The registry and the inherited-default
|
|
78
|
+
resolution are always on, and the engine's `UserMailer` already calls
|
|
79
|
+
`resolved_url`, so an app sends branded email whether or not it draws the page.
|
|
80
|
+
|
|
81
|
+
**`/admin/email_images` is deprecated, not deleted.** It still renders, and its
|
|
82
|
+
`admin_email_images_path` / `admin_email_image_path` helpers still resolve —
|
|
83
|
+
`mcritchie-studio` and `turf-monster` both have tests on `main` that drive it,
|
|
84
|
+
and consumer CI runs each consumer's default branch. A later engine minor deletes
|
|
85
|
+
the controller, view, and routes once no consumer references them. In the
|
|
86
|
+
meantime the old page had its own reporting bug fixed too: it now reads
|
|
87
|
+
`preview_url`, so it no longer claims "No image yet" about an email that is
|
|
88
|
+
visibly sending one.
|
|
89
|
+
|
|
90
|
+
- The table is the primary view: **name + live image on every row**, so an email
|
|
91
|
+
is identifiable at a glance.
|
|
92
|
+
- Each row says whether the live image is the **inherited default** or
|
|
93
|
+
**app-owned**, and an app-owned image can be reverted back to the default.
|
|
94
|
+
- Uploading goes through the **standard crop modal**, not a bare file input.
|
|
95
|
+
|
|
96
|
+
**Fix — the page misreported what was actually shipping.** It read only the S3
|
|
97
|
+
override, so it announced *"No image yet — emails send without a banner until you
|
|
98
|
+
upload one"* for an email that was visibly sending a banner from a committed repo
|
|
99
|
+
asset. `current_url` now resolves what really ships, and the copy distinguishes
|
|
100
|
+
"inherited default" from "no image at all".
|
|
101
|
+
|
|
102
|
+
**New: `Studio.s3_key_prefix`** — an optional key namespace inside the bucket, so
|
|
103
|
+
a satellite app can share an already-provisioned bucket instead of standing up
|
|
104
|
+
its own pair:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
config.s3_bucket_prefix = "mcritchie-studio"
|
|
108
|
+
config.s3_key_prefix = "mcritchie-industries/"
|
|
109
|
+
# -> s3://mcritchie-studio-dev/mcritchie-industries/email_banners/...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`Studio::S3` applies it to `upload`, `download`, `url`, `signed_url`, `exists?`,
|
|
113
|
+
`delete`, and `list` — callers keep passing logical keys and never see it, and
|
|
114
|
+
`list` strips it back off so a result feeds straight into `download`/`url`.
|
|
115
|
+
**Unset by default, so every already-shipped app's keys are byte-identical.**
|
|
116
|
+
Also new: `Studio::S3.configured?`, for callers that must degrade rather than
|
|
117
|
+
rescue `NotConfigured`.
|
|
118
|
+
|
|
119
|
+
**Honest degradation.** An app whose host never set `s3_bucket_prefix` renders
|
|
120
|
+
`/admin/emails` **read-only** — showing the inherited defaults it is genuinely
|
|
121
|
+
sending, naming the one setting that turns uploads on — instead of 500ing on the
|
|
122
|
+
first upload.
|
|
123
|
+
|
|
124
|
+
**New partial: `studio/modals/scoped_host`** — a self-contained modal host on its
|
|
125
|
+
own Alpine store, for a page that must bring its own modals. `/admin/emails` uses
|
|
126
|
+
it (`store: "emailModals"`) to ship its crop + saving modals, so the page works
|
|
127
|
+
identically in an app that renders a shared modal host and one that renders none
|
|
128
|
+
at all. `imageUploadHost` and `submitFormWithProgress` gained a matching `store`
|
|
129
|
+
option (default `"modals"`, so existing call sites are unchanged); the crop-photo
|
|
130
|
+
and saving partials already had one.
|
|
131
|
+
|
|
132
|
+
It is a **separate partial rather than a local on `studio/modals/_host`** for a
|
|
133
|
+
reason worth knowing: this is a non-isolated engine, so an app view at the same
|
|
134
|
+
path shadows the engine's — and `mcritchie-studio` and `turf-monster` both ship
|
|
135
|
+
their own `app/views/studio/modals/_host.html.erb`. A page rendering
|
|
136
|
+
`studio/modals/host` in those apps silently gets the app's fork, which knows
|
|
137
|
+
nothing of a `store:` local, so the page-scoped store is never registered and
|
|
138
|
+
every modal trigger on the page does nothing. `scoped_host` is unforked in every
|
|
139
|
+
app. The living style guide's hand-rolled `dsModals` host can rebase onto it.
|
|
140
|
+
|
|
141
|
+
Register modals with `current()?.id`, not `current().id` — the outer template
|
|
142
|
+
unmounts one tick after the stack empties, so a bare `.id` throws on close.
|
|
143
|
+
|
|
144
|
+
**Compatibility.** `Studio::EmailImage.url(key)` keeps its **pre-registry
|
|
145
|
+
contract** — this app's own uploaded image, or `nil`. The two-layer resolution
|
|
146
|
+
lives in the new `resolved_url(key)`. This matters: `turf-monster`'s mailer is
|
|
147
|
+
`Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`,
|
|
148
|
+
and making `url` resolve to the engine default would have turned that `||` into
|
|
149
|
+
dead code and silently replaced turf-monster's committed branded banner with the
|
|
150
|
+
engine's **placeholder** in live sign-in email. A method whose signature is
|
|
151
|
+
unchanged but whose return value flips from `nil` to a value is not additive.
|
|
152
|
+
|
|
153
|
+
**Consumer note.** Nothing here is required to upgrade. To adopt: set
|
|
154
|
+
`config.draw_admin_emails_routes = true`, link `admin_emails_path` from the app's
|
|
155
|
+
admin sidebar, register any extra workflows, switch mailers from `url` to
|
|
156
|
+
`resolved_url` where you want the inherited default, and drop any local
|
|
157
|
+
`branded_mailer.html.erb` fork — the engine's copy is app-name-aware through
|
|
158
|
+
`Studio.app_name`.
|
|
159
|
+
|
|
7
160
|
**The local-review link now signs the reviewer in as someone who can SEE the
|
|
8
161
|
page.** `/_studio/local_review` — the local half of the task board's WAITING
|
|
9
162
|
APPROVAL button — provisions the account before it mints, at the new
|
data/README.md
CHANGED
|
@@ -24,6 +24,7 @@ Then `bundle install`. The current release is **v0.6.1**; see [`CHANGELOG.md`](.
|
|
|
24
24
|
- **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls, `studio/banners/impersonation`, and an opt-in `Studio::Impersonation` concern for Act As session conventions.
|
|
25
25
|
- **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
|
|
26
26
|
- **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
|
|
27
|
+
- **Transactional emails**: `Studio::EmailImage` registry + the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
|
|
27
28
|
|
|
28
29
|
## Configuration
|
|
29
30
|
|
|
@@ -195,6 +196,39 @@ the block:
|
|
|
195
196
|
<% end %>
|
|
196
197
|
```
|
|
197
198
|
|
|
199
|
+
**Page-scoped hosts — `studio/modals/scoped_host`.** When a page must bring its
|
|
200
|
+
own modals (because not every consuming app renders a shared host, and the ones
|
|
201
|
+
that do register their own modal set), render a second host on its own Alpine
|
|
202
|
+
store:
|
|
203
|
+
|
|
204
|
+
```erb
|
|
205
|
+
<%= render "studio/modals/scoped_host", store: "emailModals" do %>
|
|
206
|
+
<template x-if="$store.emailModals.current()?.id === 'crop-photo'">
|
|
207
|
+
<%= render "studio/modals/crop_photo", store: "emailModals" %>
|
|
208
|
+
</template>
|
|
209
|
+
<% end %>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`studio/modals/_crop_photo` and `_saving` take the same `store:` local, and
|
|
213
|
+
`imageUploadHost({ store: "emailModals", ... })` /
|
|
214
|
+
`submitFormWithProgress(form, { store: "emailModals" })` route through it — all
|
|
215
|
+
default to `"modals"`, so existing call sites are unchanged. The engine's
|
|
216
|
+
`/admin/emails` is the live example.
|
|
217
|
+
|
|
218
|
+
Two things that will bite you:
|
|
219
|
+
|
|
220
|
+
- **Render `scoped_host`, not `host`.** This is a non-isolated engine, so an app
|
|
221
|
+
view at the same path shadows the engine's — and `mcritchie-studio` and
|
|
222
|
+
`turf-monster` both ship their own `app/views/studio/modals/_host.html.erb`.
|
|
223
|
+
A page rendering `studio/modals/host` in those apps silently gets the app's
|
|
224
|
+
fork. `scoped_host` is unforked everywhere.
|
|
225
|
+
- **Guard registrations with `current()?.id`.** The outer template unmounts one
|
|
226
|
+
tick *after* the stack empties, so a bare `.id` throws on every close.
|
|
227
|
+
|
|
228
|
+
The scoped host takes its animations from `engine-motion.css` rather than an
|
|
229
|
+
inline copy, so a consumer bundling that layer gets the same spring as the shared
|
|
230
|
+
host.
|
|
231
|
+
|
|
198
232
|
Store API (`Alpine.store('modals')`):
|
|
199
233
|
|
|
200
234
|
| Call | Behavior |
|
|
@@ -268,6 +302,129 @@ The legacy string locals (`balance_html`, `extra_icons_html`, `div2_html` —
|
|
|
268
302
|
pre-rendered HTML injected via `raw`) are deprecated but still honored when the
|
|
269
303
|
matching slot is absent, so existing call sites render unchanged.
|
|
270
304
|
|
|
305
|
+
## Transactional emails
|
|
306
|
+
|
|
307
|
+
Every consuming app can render the same admin page — **`/admin/emails`** —
|
|
308
|
+
listing each transactional email it sends with the banner riding at the top of
|
|
309
|
+
that email, and whether that banner is the **inherited default** or an
|
|
310
|
+
**app-owned override**. Supersedes the old `/admin/email_images`, which is
|
|
311
|
+
deprecated but still renders for one release.
|
|
312
|
+
|
|
313
|
+
**The page is opt-in:**
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
# config/initializers/studio.rb
|
|
317
|
+
config.draw_admin_emails_routes = true
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Off by default because `turf-monster` already owns `/admin/emails` and both of
|
|
321
|
+
its helper names; drawing them there raises at route-load and kills every route
|
|
322
|
+
in the app. The gate covers only the page — the registry and the inherited
|
|
323
|
+
defaults are always on.
|
|
324
|
+
|
|
325
|
+
### The registry
|
|
326
|
+
|
|
327
|
+
A registered email is mostly symbolic of a workflow — a key, a label, a
|
|
328
|
+
description. The only real asset is its image. The engine pre-registers the two
|
|
329
|
+
every Studio app sends, so a new app inherits both without declaring anything:
|
|
330
|
+
|
|
331
|
+
| Key | Label |
|
|
332
|
+
|-----|-------|
|
|
333
|
+
| `magic_link` | Magic-link sign-in |
|
|
334
|
+
| `email_change_confirmation` | Email change confirmation |
|
|
335
|
+
|
|
336
|
+
A host adds its own workflows from an initializer, mirroring
|
|
337
|
+
`Studio::ModelPage.register`:
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
# config/initializers/studio_emails.rb
|
|
341
|
+
Rails.application.config.to_prepare do
|
|
342
|
+
Studio::EmailImage.register("winnings", label: "Contest winnings",
|
|
343
|
+
description: "Sent when a player wins a contest.")
|
|
344
|
+
Studio::EmailImage.register("wallet_export", label: "Wallet export")
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Re-registering an inherited key updates it in place and keeps its position, so
|
|
349
|
+
relabeling `magic_link` does not reorder the page or drop its default artwork.
|
|
350
|
+
|
|
351
|
+
### Resolution — inherit, then own
|
|
352
|
+
|
|
353
|
+
```
|
|
354
|
+
Studio::EmailImage.resolved_url(:magic_link)
|
|
355
|
+
1. this app's ImageCache row (its own S3 bucket) -> app-owned override
|
|
356
|
+
2. the engine's default gem asset -> inherited default
|
|
357
|
+
3. nil -> sends bannerless
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Note the method: **`resolved_url` walks all three layers; `url` returns only
|
|
361
|
+
layer 1** (this app's own image, or nil). That split is deliberate — it keeps
|
|
362
|
+
every caller written before the registry behaving exactly as it did. A mailer
|
|
363
|
+
that already falls back on its own, `url(:magic_link) || own_banner`, must stay
|
|
364
|
+
on `url`; moving it to `resolved_url` makes that fallback unreachable and swaps
|
|
365
|
+
the app's committed artwork for the engine's default.
|
|
366
|
+
|
|
367
|
+
Defaults **ride the gem** (`app/assets/images/emails/*`), so a brand-new app with
|
|
368
|
+
an empty bucket sends branded email on day one and needs no cross-app S3
|
|
369
|
+
permission. Uploading on an app's `/admin/emails` writes to **that** app's bucket
|
|
370
|
+
and **that** app's `image_caches` row — which is exactly "the asset now belongs
|
|
371
|
+
to this app". Every app has its own bucket and table, so an override never leaks
|
|
372
|
+
between apps.
|
|
373
|
+
|
|
374
|
+
Three accessors, and picking the wrong one changes what real people receive:
|
|
375
|
+
|
|
376
|
+
| Call | Returns | Use for |
|
|
377
|
+
|---|---|---|
|
|
378
|
+
| `url(key)` | this app's **own** image, or `nil` | the pre-registry contract; a host doing its own `\|\| fallback` |
|
|
379
|
+
| `resolved_url(key)` | own → inherited default → `nil`, **absolute** | mailers |
|
|
380
|
+
| `preview_url(key)` | own → inherited default (root-relative) | the admin page |
|
|
381
|
+
|
|
382
|
+
`url` deliberately does **not** resolve to the default. `turf-monster`'s mailer
|
|
383
|
+
reads `Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`
|
|
384
|
+
— making `url` resolve would turn that `||` into dead code and swap its committed
|
|
385
|
+
branded banner for the engine placeholder in live email.
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
class UserMailer < ApplicationMailer
|
|
389
|
+
layout "branded_mailer"
|
|
390
|
+
|
|
391
|
+
def magic_link(email, token)
|
|
392
|
+
@banner_url = Studio::EmailImage.resolved_url(:magic_link) # nil renders bannerless
|
|
393
|
+
# ...
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Do **not** fork `layouts/branded_mailer.html.erb` — the engine's copy is
|
|
399
|
+
app-name-aware through `Studio.app_name`.
|
|
400
|
+
|
|
401
|
+
### Sharing a bucket — `s3_key_prefix`
|
|
402
|
+
|
|
403
|
+
Uploads need `Studio.s3_bucket_prefix`. A satellite app that has no bucket of its
|
|
404
|
+
own can share an existing one under its own key namespace:
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
config.s3_bucket_prefix = "mcritchie-studio"
|
|
408
|
+
config.s3_key_prefix = "mcritchie-industries/"
|
|
409
|
+
# -> s3://mcritchie-studio-dev/mcritchie-industries/email_banners/...
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
`Studio::S3` applies the prefix to every operation, so callers keep passing
|
|
413
|
+
logical keys and never see it. Unset (the default) leaves keys byte-identical to
|
|
414
|
+
what every already-shipped app wrote.
|
|
415
|
+
|
|
416
|
+
An app with **no** bucket configured does not error — `/admin/emails` renders
|
|
417
|
+
read-only, showing the inherited defaults it is genuinely sending and naming the
|
|
418
|
+
one setting that turns uploads on.
|
|
419
|
+
|
|
420
|
+
### Linking it
|
|
421
|
+
|
|
422
|
+
Add it to the app's admin sidebar section:
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
{ label: "Emails", href: admin_emails_path, emoji: "✉️", desc: "Transactional email banners" }
|
|
426
|
+
```
|
|
427
|
+
|
|
271
428
|
## Overriding Views
|
|
272
429
|
|
|
273
430
|
This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
|
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
module Studio
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# DEPRECATED — superseded by Studio::EmailsController (/admin/emails), which
|
|
3
|
+
# shows every registered email with its live image, says whether that image is
|
|
4
|
+
# the inherited default or app-owned, and uploads through the crop modal.
|
|
5
|
+
#
|
|
6
|
+
# Kept alive for ONE release on purpose, not by neglect. consumer-ci.yml checks
|
|
7
|
+
# out each consumer repo with no `ref:` — i.e. its DEFAULT BRANCH — and runs
|
|
8
|
+
# that suite against the engine PR. mcritchie-studio and turf-monster both have
|
|
9
|
+
# tests on `main` that drive this page and its admin_email_image_path helper,
|
|
10
|
+
# so deleting it here would redden their lanes from the moment the PR opens,
|
|
11
|
+
# and nothing inside the engine PR could fix it: the consumer PRs would have to
|
|
12
|
+
# travel accepted -> release -> main in BOTH apps before this could go green.
|
|
13
|
+
#
|
|
14
|
+
# So the retirement is staged: this release adds /admin/emails and leaves this
|
|
15
|
+
# page working; each app's adoption task moves its link and its tests; a later
|
|
16
|
+
# engine minor deletes this file, its view, and its two routes once no
|
|
17
|
+
# consumer's main references them.
|
|
18
|
+
#
|
|
19
|
+
# It is not frozen in its broken state, though — see #index.
|
|
5
20
|
class EmailImagesController < ApplicationController
|
|
6
21
|
before_action :require_admin
|
|
7
22
|
|
|
@@ -11,6 +26,26 @@ module Studio
|
|
|
11
26
|
@variants = Studio::EmailImage.variants
|
|
12
27
|
end
|
|
13
28
|
|
|
29
|
+
# The canonical page this one is being retired in favour of, or nil when this
|
|
30
|
+
# app has not drawn it. Rendered as a banner at the top of the old view so an
|
|
31
|
+
# admin who lands here by a stale link is walked forward.
|
|
32
|
+
#
|
|
33
|
+
# The nil case is REAL, not defensive padding: /admin/emails is opt-in
|
|
34
|
+
# (Studio.draw_admin_emails_routes, default off because turf-monster owns the
|
|
35
|
+
# name), so on any app that has not opted in the helper does not exist and a
|
|
36
|
+
# bare call raises NameError — which 500s this page. Consumer CI caught
|
|
37
|
+
# exactly that on mcritchie-studio.
|
|
38
|
+
#
|
|
39
|
+
# Asks the ROUTER rather than the config flag: the flag is read at route-draw
|
|
40
|
+
# time, so a host that flips it afterwards would still have no route, and the
|
|
41
|
+
# router is the thing that actually knows.
|
|
42
|
+
def successor_path
|
|
43
|
+
return nil unless Rails.application.routes.named_routes.key?(:admin_emails)
|
|
44
|
+
|
|
45
|
+
admin_emails_path
|
|
46
|
+
end
|
|
47
|
+
helper_method :successor_path
|
|
48
|
+
|
|
14
49
|
# PATCH /admin/email_images/:variant — upload/replace a banner.
|
|
15
50
|
def update
|
|
16
51
|
variant = params[:variant].to_s
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
# /admin/emails — the standard transactional-email page every Studio app gets,
|
|
3
|
+
# modelled on the living style guide (/admin/style): a plain host-inherited
|
|
4
|
+
# controller whose view is a bare content wrapper, so it renders inside each
|
|
5
|
+
# host's application layout and picks up that app's navbar and theme.
|
|
6
|
+
#
|
|
7
|
+
# It lists Studio::EmailImage's registry — one row per registered email, each
|
|
8
|
+
# showing its live banner and whether that banner is the INHERITED engine
|
|
9
|
+
# default or an APP-OWNED override — and writes an override through the shared
|
|
10
|
+
# crop modal. Replaces /admin/email_images, which now redirects here.
|
|
11
|
+
#
|
|
12
|
+
# An app whose host never set Studio.s3_bucket_prefix cannot store an override.
|
|
13
|
+
# That is a read-only page, not an error: uploads_available? gates the write
|
|
14
|
+
# actions and the view explains why, so the page still shows what each email
|
|
15
|
+
# is currently sending.
|
|
16
|
+
class EmailsController < ApplicationController
|
|
17
|
+
before_action :require_admin
|
|
18
|
+
before_action :load_entry, only: %i[update destroy]
|
|
19
|
+
before_action :require_uploads, only: %i[update destroy]
|
|
20
|
+
|
|
21
|
+
MAX_BYTES = 8.megabytes
|
|
22
|
+
|
|
23
|
+
def index
|
|
24
|
+
@entries = Studio::EmailImage.entries
|
|
25
|
+
@uploads_available = Studio::EmailImage.uploads_available?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# PATCH /admin/emails/:key — upload/replace this app's own banner.
|
|
29
|
+
def update
|
|
30
|
+
file = params[:image]
|
|
31
|
+
unless valid_image?(file)
|
|
32
|
+
message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
|
|
33
|
+
return redirect_to admin_emails_path, alert: message, status: :see_other
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
rescue_and_log do
|
|
37
|
+
Studio::EmailImage.store(@key, io: file, content_type: file.content_type)
|
|
38
|
+
redirect_to admin_emails_path, notice: "#{Studio::EmailImage.label(@key)} banner updated.", status: :see_other
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError
|
|
41
|
+
redirect_to admin_emails_path, alert: "Couldn't save the image. Please try again.", status: :see_other
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# DELETE /admin/emails/:key — drop this app's override and fall back to the
|
|
45
|
+
# inherited default.
|
|
46
|
+
def destroy
|
|
47
|
+
# rescue_and_log, like #update: destroy is a WRITE path (it drops an
|
|
48
|
+
# ImageCache row and deletes the S3 object behind it), and the bare rescue
|
|
49
|
+
# below turns any failure into a friendly alert. Without the log that
|
|
50
|
+
# failure is invisible — the admin sees "try again" and nothing reaches
|
|
51
|
+
# ErrorLog to say why.
|
|
52
|
+
rescue_and_log do
|
|
53
|
+
reverted = Studio::EmailImage.revert(@key)
|
|
54
|
+
notice = if reverted
|
|
55
|
+
"#{Studio::EmailImage.label(@key)} reverted to the inherited default."
|
|
56
|
+
else
|
|
57
|
+
"#{Studio::EmailImage.label(@key)} was already using the inherited default."
|
|
58
|
+
end
|
|
59
|
+
redirect_to admin_emails_path, notice: notice, status: :see_other
|
|
60
|
+
end
|
|
61
|
+
rescue StandardError
|
|
62
|
+
redirect_to admin_emails_path, alert: "Couldn't revert the image. Please try again.", status: :see_other
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def load_entry
|
|
68
|
+
@key = params[:key].to_s
|
|
69
|
+
head :not_found unless Studio::EmailImage.known?(@key)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def require_uploads
|
|
73
|
+
return if Studio::EmailImage.uploads_available?
|
|
74
|
+
|
|
75
|
+
redirect_to admin_emails_path, status: :see_other,
|
|
76
|
+
alert: "This app has no object storage configured, so email images can't be changed here yet."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def valid_image?(file)
|
|
80
|
+
file.respond_to?(:content_type) &&
|
|
81
|
+
file.content_type.to_s.start_with?("image/") &&
|
|
82
|
+
file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_BYTES
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/app/mailers/user_mailer.rb
CHANGED
|
@@ -21,7 +21,10 @@ class UserMailer < ApplicationMailer
|
|
|
21
21
|
@app_name = Studio.app_name
|
|
22
22
|
@email = email
|
|
23
23
|
@magic_url = magic_link_url_for(token)
|
|
24
|
-
|
|
24
|
+
# resolved_url, not url: this app's own upload if it has one, otherwise the
|
|
25
|
+
# engine's default banner — which is what makes a brand-new app's sign-in
|
|
26
|
+
# email branded on day one. nil renders bannerless.
|
|
27
|
+
@banner_url = Studio::EmailImage.resolved_url(:magic_link)
|
|
25
28
|
@banner_alt = "Your #{@app_name} sign-in link"
|
|
26
29
|
mail(to: email, subject: "Your #{@app_name} sign-in link")
|
|
27
30
|
end
|