studio-engine 0.143.2 → 0.144.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 +16 -0
- data/Gemfile +7 -0
- data/README.md +66 -12
- data/app/assets/tailwind/studio_engine/engine.css +9 -0
- data/app/components/previews/studio/button_component_preview.rb +2 -2
- data/app/components/previews/studio/table_component_preview/rows.html.erb +1 -1
- data/app/components/studio/button_component.rb +13 -4
- data/app/components/studio/table_component.html.erb +3 -2
- data/app/components/studio/table_component.rb +39 -5
- data/app/controllers/studio/modals_controller.rb +32 -1
- data/app/javascript/studio/modal_host.js +291 -32
- data/app/views/studio/modals/_host.html.erb +23 -8
- data/app/views/style/_primitives.html.erb +3 -2
- data/lib/studio/modals.rb +57 -5
- data/lib/studio/system_test_case.rb +170 -0
- data/lib/studio/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98d7d512ad7dbca4f6610b95f82ea7c0975450f9150522a4a68e89df5f392bf5
|
|
4
|
+
data.tar.gz: 8b2e116b30cc9a8d7d927a1f1bfd1eb155fc02d7cbbbbc0f6dccd4f33b41446e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c58133319c6e482aca21bbfe8a5478c59b285b01ce5b1cbeed3136dd9e64138d94c5a3cfec3f590f6ddf35dd7bc1084271efc078faa7e3cb868b162af9f1d15
|
|
7
|
+
data.tar.gz: 7aba5316e9c073cd58359bac595c3ef9d2f9b02cecedadfa80131d9effc6b343a86c5d0df89db380bc2b17e2bd59a66ba5215e976fd8b0a3b3986e8fbcbe1caa
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.144.0 — 2026-11-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`Studio::SystemTestCase`, one shared system-test base.** The browser harness fixes that each app carried on its own now ship in the gem: the window resets to `desktop_size` before every test, `evaluate_async_script` gives back the script timeout Capybara 3.40.0 leaves at 2s, a phone viewport's DevTools emulation is cleared after every test, and Selenium gets a 180s read timeout. Helpers `phone_viewport!`, `assert_viewport_width` and `clear_emulation` come with it. An app adopts with `require "studio/system_test_case"` and `class ApplicationSystemTestCase < Studio::SystemTestCase`, keeps its own settings, setup, helpers and tests, and opts out of one hook by name with `skip_harness_hook :name, reason:`, which is logged. `lib/studio.rb` does not load it. See `docs/SYSTEM_TESTS.md`.
|
|
12
|
+
- **`Studio::TableComponent` takes `caption:` and `aria_label:`, and its scroll wrapper is keyboard-scrollable.** The wrapper is always `tabindex="0"` with an inset focus ring, so a keyboard user can focus it and scroll a wide table with the arrow keys. `caption:` renders a visually hidden `<caption>`; `aria_label:` labels the table without one. Either one also makes the wrapper a labelled `role="region"`. Pass one on every table.
|
|
13
|
+
- **`Studio::ButtonComponent` gains `tone: :outline`** (`btn-outline`: a primary border, unfilled until hover), drawn in light and dark on `/admin/style` and pinned by the screenshot baselines.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **`Studio::ButtonComponent`'s default `md` size puts its label on the type scale.** It now adds the new `btn-md` utility: body size (1x `--type-base`, 16px; 14px under `type-dense`) at a 1.25 line height, so the button grows no taller. A bare `.btn` keeps `text-sm`, so hand-built buttons do not move. A host that relied on the 14px component label sees it at 16px.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **On-demand modals (`Studio.modals`) are hardened before Turf adopts them.** A fetch that does not answer within `Studio.modals.fetch_timeout_ms` (default 8000) is aborted and shows the error card, which now has Try again and a Close that works on a card opened `dismissible: false`. The fragment cache is dropped when the session changes by Turbo visit (sign-in, sign-out, a user switch), keyed on `Studio::Modals.session_marker`, so no cached fragment carries a dead CSRF token or the prior user's data. A fragment containing a `<script>` is refused, not inserted (the reason shows on the card in development and test). The loading card is `role="status"`. Prefetch also runs after each Turbo visit. No change for a consumer: `$store.modals.open(id, props, opts)` is unchanged; set `fetch_timeout_ms` in the initializer to change the wait.
|
|
22
|
+
|
|
7
23
|
## 0.143.2 — 2026-11-10
|
|
8
24
|
|
|
9
25
|
## 0.143.1 — 2026-11-10
|
data/Gemfile
CHANGED
|
@@ -29,6 +29,13 @@ group :development, :test do
|
|
|
29
29
|
# Rails.application directly rather than growing Rails-app scaffolding inside a
|
|
30
30
|
# test fixture. Not a runtime dependency — consuming apps bring their own server.
|
|
31
31
|
gem "puma", ">= 6.0"
|
|
32
|
+
|
|
33
|
+
# Capybara and selenium-webdriver drive Studio::SystemTestCase's own tests
|
|
34
|
+
# (test/lib/system_test_case_test.rb, test/system/). The gem does not declare
|
|
35
|
+
# them: lib/studio.rb never loads lib/studio/system_test_case.rb, and every
|
|
36
|
+
# host that inherits the base bundles both in its own test group.
|
|
37
|
+
gem "capybara", ">= 3.39"
|
|
38
|
+
gem "selenium-webdriver", ">= 4.20"
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
# solana-studio backs the style guide's THREE web3 specimens (Connect wallet and
|
data/README.md
CHANGED
|
@@ -55,6 +55,7 @@ resolved.
|
|
|
55
55
|
- **Connections**: generic OAuth 2.0 tooling to connect a third-party account. A host declares each provider with `Studio.define_connection` (endpoints, static or per-account; scopes; form or HTTP Basic client auth; a rotating, cached or client-credentials refresh policy). The engine gives it `Studio::Connection` (one table, tokens encrypted, `bin/rails g studio:connections`), the connect, callback and disconnect actions (`Studio::ConnectionFlow`) behind the host's admin guard, `Studio.draw_connection_routes`, an exact-key log filter, and a stand-in for the e2e lane. The tooling names no provider; the engine's own definitions are the Integrations below. See [`docs/CONNECTIONS.md`](docs/CONNECTIONS.md).
|
|
56
56
|
- **Integrations**: ready connection definitions on that tooling for QuickBooks, Egnyte and Microsoft 365: the URLs, scopes, token behaviour and a minimal describe, never a reader. A host turns one on with `Studio.enable_integration :quickbooks, env: { client_id: "...", client_secret: "..." }` (env var names) and mounts the admin page with `Studio.draw_integrations_routes(self)`: a list with each status and Connect, and one page per integration with Disconnect and a host panel for the host's own live read. See [`docs/INTEGRATIONS.md`](docs/INTEGRATIONS.md).
|
|
57
57
|
- **Surveys**: define a survey in app code (`config/surveys/*.rb`, `Studio.define_survey`) with six question types — emoji scale, 1–5 rating, single and multiple choice, short and long text — and the engine serves it at `/surveys/:slug`: one question per screen, autosave, resume, keyboard shortcuts, and a no-JavaScript fallback. `config.on_survey_completed` and `config.survey_ref_resolver` let the app fire its own goals and attribute email arrivals. An admin panel at `/admin/surveys` breaks results down per question and exports CSV. Opt in with `config.draw_survey_routes = true` and `config.draw_admin_survey_routes = true`. See [`docs/SURVEYS.md`](docs/SURVEYS.md).
|
|
58
|
+
- **System tests**: `Studio::SystemTestCase`, the one Capybara base every host's `ApplicationSystemTestCase` inherits (`require "studio/system_test_case"`). It resets the window before each test, restores the script timeout Capybara 3.40.0's `evaluate_async_script` leaves at 2s, clears phone emulation after each test, and gives Selenium a 180s read timeout. An app keeps its own settings (`phone_width`), setup, helpers and tests, and opts out of one hook by name with `skip_harness_hook`, which is logged. See [`docs/SYSTEM_TESTS.md`](docs/SYSTEM_TESTS.md).
|
|
58
59
|
|
|
59
60
|
## Tailwind
|
|
60
61
|
|
|
@@ -309,6 +310,7 @@ size and state live (Components section), and the gallery has a preview per stat
|
|
|
309
310
|
<%= render Studio::ButtonComponent.new(label: "Save", type: "submit") %>
|
|
310
311
|
<%= render Studio::ButtonComponent.new(label: "Delete", tone: :danger, size: :sm) %>
|
|
311
312
|
<%= render Studio::ButtonComponent.new(label: "Docs", tone: :quiet, href: docs_path) %>
|
|
313
|
+
<%= render Studio::ButtonComponent.new(label: "Export", tone: :outline) %>
|
|
312
314
|
<%= render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…") %>
|
|
313
315
|
|
|
314
316
|
<%= render(Studio::EmptyStateComponent.new(detail: "Invite someone to start.")) do |empty| %>
|
|
@@ -318,6 +320,7 @@ size and state live (Components section), and the gallery has a preview per stat
|
|
|
318
320
|
|
|
319
321
|
<%= render Studio::TableComponent.new(
|
|
320
322
|
rows: @users, sort: params[:sort], direction: params[:direction], empty: "No users yet.",
|
|
323
|
+
caption: "Team members",
|
|
321
324
|
columns: [
|
|
322
325
|
{ key: :name, label: "User", sortable: true },
|
|
323
326
|
{ key: :created_at, label: "Joined", align: :right, cell: ->(u) { l(u.created_at.to_date) } }
|
|
@@ -326,11 +329,19 @@ size and state live (Components section), and the gallery has a preview per stat
|
|
|
326
329
|
|
|
327
330
|
- **Button.** Tones map onto the shipped utilities: `primary` is `btn-primary`,
|
|
328
331
|
`secondary` is `btn-secondary` (the success role, or the app's
|
|
329
|
-
`--btn-secondary-*` retune), `quiet` is `btn-neutral`, `danger` is `btn-danger
|
|
330
|
-
|
|
332
|
+
`--btn-secondary-*` retune), `quiet` is `btn-neutral`, `danger` is `btn-danger`,
|
|
333
|
+
`outline` is `btn-outline` (a primary border, unfilled until hover).
|
|
334
|
+
Sizes are `btn-sm`, `btn-md` and `btn-lg`. `btn-md` puts the medium label on
|
|
335
|
+
the type scale at body size (1x `--type-base`); a bare `btn` keeps `text-sm`,
|
|
336
|
+
so hand-built buttons do not move. `loading:` uses the aria-busy
|
|
331
337
|
contract (docs/LOADER.md): the button is `aria-busy` and disabled and shows the
|
|
332
338
|
pending spinner. A submit button always carries the hidden spinner, so a Turbo
|
|
333
339
|
form that marks itself busy shows it with no script. A link cannot be loading.
|
|
340
|
+
**A busy form hides the icon on every button in it**, not only on the submit
|
|
341
|
+
button: the aria-busy contract hides every `[data-loader-idle]` under the busy
|
|
342
|
+
element, and the icon slot is idle content. A Cancel or Back button with an
|
|
343
|
+
icon inside the form loses its icon while the form submits. Put such a button
|
|
344
|
+
outside the form, or give it no icon.
|
|
334
345
|
- **Empty state.** The `.empty-state` card, the message at `type-body`, the
|
|
335
346
|
detail at `type-fine`, an optional icon, and at most one action, which is a
|
|
336
347
|
`Studio::ButtonComponent`. `framed: false` drops the card. The
|
|
@@ -341,7 +352,10 @@ size and state live (Components section), and the gallery has a preview per stat
|
|
|
341
352
|
`sort_url:` takes a callable for another URL shape. No rows renders one row with
|
|
342
353
|
a bare empty state. On a phone the table scrolls sideways, as every engine table
|
|
343
354
|
does; `min_width:` sets where. `sticky_header: true` adds
|
|
344
|
-
`data-sticky-table-header`.
|
|
355
|
+
`data-sticky-table-header`. Give every table a name: `caption:` renders a
|
|
356
|
+
visually hidden `<caption>`, `aria_label:` labels the table without one. The
|
|
357
|
+
scroll wrapper is always `tabindex="0"`, so a keyboard user can scroll it, and a
|
|
358
|
+
name also makes it a labelled `role="region"`.
|
|
345
359
|
|
|
346
360
|
### The page header — `studio/_page_header`
|
|
347
361
|
|
|
@@ -737,11 +751,22 @@ change: `$store.modals.open("contest_rules", props)` works as before.
|
|
|
737
751
|
|--------|---------|
|
|
738
752
|
| `partial:` | The content partial, as the layout rendered it. The two rules below still apply (one root, no double quote in `x-data`). |
|
|
739
753
|
| `auth:` | `:any` (default) or `:user`. A `:user` modal answers `401` to a signed-out request and is never prefetched for one. |
|
|
740
|
-
| `prefetch:` | `true` fetches the modal when the browser idles after load (`requestIdleCallback`, else a timeout), so it opens with no wait. Use it for a modal opened on most visits. |
|
|
754
|
+
| `prefetch:` | `true` fetches the modal when the browser idles after load and after every Turbo visit (`requestIdleCallback`, else a timeout), so it opens with no wait. Use it for a modal opened on most visits. |
|
|
741
755
|
| `locals:` | A Hash, a Symbol naming a helper the view answers (an existing `*_modal_locals` helper), or a callable taking the view. Resolved per request, in the view. |
|
|
742
756
|
|
|
743
757
|
Ids are lower-case letters, digits, `-` and `_`.
|
|
744
758
|
|
|
759
|
+
**Locals run in the modal endpoint, not on the page.** The view that resolves
|
|
760
|
+
`locals:` belongs to `Studio::ModalsController`, so it has the helpers and the
|
|
761
|
+
session (`current_user`, `logged_in?`) but none of the page's instance
|
|
762
|
+
variables. A `*_modal_locals` helper that reads `@contest` or any other
|
|
763
|
+
page-scoped ivar gets `nil` there. Such a helper cannot move as it is: derive
|
|
764
|
+
what it needs from the session or the user, or keep that modal in the layout.
|
|
765
|
+
|
|
766
|
+
**The fetch timeout.** `Studio.modals.fetch_timeout_ms` (default `8000`) is how
|
|
767
|
+
long the host waits for a fragment. Set it in the initializer:
|
|
768
|
+
`Studio.modals.fetch_timeout_ms = 12_000`.
|
|
769
|
+
|
|
745
770
|
**What happens.** The host advertises the registered ids on its template
|
|
746
771
|
(`data-modal-host-remote-value`). `open(id)` for one that is not in the page
|
|
747
772
|
yet fetches `GET /_studio/modals/<id>` (`Studio::ModalsController`), wraps the
|
|
@@ -749,12 +774,41 @@ fragment in the same `<template x-if>` a layout would render, appends it to the
|
|
|
749
774
|
card, and opens. A fetch that answers within 150 ms opens with no loading state.
|
|
750
775
|
A slower one opens the card with the engine's processing card, held at least
|
|
751
776
|
`StudioModals.MIN_LOAD_MS`, then the content replaces it. A failed fetch (a
|
|
752
|
-
network error, a `500`, a `401`,
|
|
753
|
-
engine's error card
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
777
|
+
network error, a `500`, a `401`, a host filter's redirect, or no answer within
|
|
778
|
+
`fetch_timeout_ms`) shows the engine's error card. **Try again** re-runs the
|
|
779
|
+
fetch with the card in place; **Close** closes it, even on a card opened
|
|
780
|
+
`dismissible: false` (Escape and the backdrop still respect that flag). The next
|
|
781
|
+
open tries again too. The loading card is `role="status"` (`aria-live`
|
|
782
|
+
polite); the error card is `role="alert"`.
|
|
783
|
+
|
|
784
|
+
**A fragment with a `<script>` is refused**, never inserted: it shows the error
|
|
785
|
+
card. In development and test the card names the reason; in production it shows
|
|
786
|
+
the generic line. Nothing is logged to the console either way; the endpoint
|
|
787
|
+
records an `ErrorLog` row instead, once per modal id per process. Put behaviour in a module or a Stimulus
|
|
788
|
+
controller.
|
|
789
|
+
|
|
790
|
+
The fragment is held across Turbo visits: a later open inserts it from memory
|
|
791
|
+
into the new page's host, once, with no second request. A fetch that lands
|
|
792
|
+
after a Turbo visit opens nothing on the new page.
|
|
793
|
+
|
|
794
|
+
**The cache follows the session.** A fragment can carry the viewer's data and a
|
|
795
|
+
form's CSRF token. The host advertises a session marker
|
|
796
|
+
(`Studio::Modals.session_marker`: a digest of the session id and the user id
|
|
797
|
+
under `Studio.session_key`). The browser compares it on every open and every
|
|
798
|
+
`turbo:load`. When it changes (sign-out, a sign-in, a user switch, by Turbo
|
|
799
|
+
visit, or a `session:changed` from studio/session that is a real identity
|
|
800
|
+
change: `signed_out`, `changed`, or another viewer's identities, never the first
|
|
801
|
+
seating at load or a `stale` → `authenticated` revalidation), the browser drops
|
|
802
|
+
every cached fragment, every fetch in flight and every template already
|
|
803
|
+
inserted, so the next open fetches fresh. A modal open at that moment keeps its
|
|
804
|
+
template until it closes: a card is never emptied in place. The session id is
|
|
805
|
+
what `reset_session` rotates, and the CSRF secret rotates with it; the user id
|
|
806
|
+
catches a sign-in that keeps the session. The `csrf-token` meta is not used: it
|
|
807
|
+
is masked anew on every render, so it would empty the cache on every visit.
|
|
808
|
+
|
|
809
|
+
**Prefetch** runs when the browser idles after the first load and again after
|
|
810
|
+
every Turbo visit (`turbo:load`). An id already cached or in flight is not
|
|
811
|
+
fetched twice.
|
|
758
812
|
|
|
759
813
|
**What does not change.** An id the registry does not name takes the old
|
|
760
814
|
synchronous `open()` path, and an app that registers nothing renders the host
|
|
@@ -766,8 +820,8 @@ One difference for a registered id: during the first open's fetch,
|
|
|
766
820
|
with no layout. It skips the host's `require_authentication` so an `:any`
|
|
767
821
|
modal serves a signed-out visitor, and answers a `:user` modal's signed-out
|
|
768
822
|
request with a bare `401`, never the `/login` redirect. Each answer is
|
|
769
|
-
`private` with `Vary: Cookie` and an ETag. A fragment carries no script
|
|
770
|
-
the page's CSP is unchanged. Only the shared host (`$store.modals`) loads on
|
|
823
|
+
`private` with `Vary: Cookie` and an ETag. A fragment carries no script (the
|
|
824
|
+
browser refuses one that does), so the page's CSP is unchanged. Only the shared host (`$store.modals`) loads on
|
|
771
825
|
demand; a scoped host renders its modals as before.
|
|
772
826
|
|
|
773
827
|
**Do not do both.** An id that the registry names and the layout still
|
|
@@ -1709,6 +1709,15 @@ body.modal-open .studio-app-banner {
|
|
|
1709
1709
|
@apply px-8 py-3 text-base rounded-xl shadow-sm;
|
|
1710
1710
|
}
|
|
1711
1711
|
|
|
1712
|
+
/* The medium button's label on the type scale: body size (1x --type-base), where a
|
|
1713
|
+
bare .btn keeps text-sm (14px, off the scale) so no host's hand-built button moves.
|
|
1714
|
+
Studio::ButtonComponent size: :md adds it. The 1.25 line height holds the bare
|
|
1715
|
+
.btn's 20px line, so the button grows no taller. Follows type-dense like type-body. */
|
|
1716
|
+
@utility btn-md {
|
|
1717
|
+
font-size: calc(var(--type-base, 16px) * 1);
|
|
1718
|
+
line-height: 1.25;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1712
1721
|
/* A busy button (Studio::ButtonComponent loading:, or a submit Turbo marks busy) is
|
|
1713
1722
|
disabled so it takes no second click, but it is working, not unavailable: it keeps
|
|
1714
1723
|
its full colour and shows the progress cursor. The busy words sit in the button's
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Studio
|
|
4
|
-
# Every state Studio::ButtonComponent can show: the
|
|
4
|
+
# Every state Studio::ButtonComponent can show: the five tones, the three
|
|
5
5
|
# sizes, a link, an icon, disabled and loading. No @param tags: the gallery
|
|
6
6
|
# takes no input.
|
|
7
7
|
class ButtonComponentPreview < ViewComponent::Preview
|
|
8
|
-
# The
|
|
8
|
+
# The five tones, at the default size.
|
|
9
9
|
def tones
|
|
10
10
|
render_with_template(locals: { tones: Studio::ButtonComponent::TONES.keys })
|
|
11
11
|
end
|
|
@@ -10,5 +10,5 @@
|
|
|
10
10
|
%>
|
|
11
11
|
<div class="card overflow-hidden" data-specimen="table">
|
|
12
12
|
<%= render Studio::TableComponent.new(rows: rows, columns: columns, sort: sort, direction: direction,
|
|
13
|
-
sort_url: ->(key, dir) { "?sort=#{key}&direction=#{dir}" }, empty: "No members yet.", min_width: 520) %>
|
|
13
|
+
sort_url: ->(key, dir) { "?sort=#{key}&direction=#{dir}" }, empty: "No members yet.", min_width: 520, caption: "Team members") %>
|
|
14
14
|
</div>
|
|
@@ -6,6 +6,7 @@ module Studio
|
|
|
6
6
|
# render Studio::ButtonComponent.new(label: "Save")
|
|
7
7
|
# render Studio::ButtonComponent.new(label: "Delete", tone: :danger, size: :sm)
|
|
8
8
|
# render Studio::ButtonComponent.new(label: "Docs", tone: :quiet, href: "/docs")
|
|
9
|
+
# render Studio::ButtonComponent.new(label: "Export", tone: :outline)
|
|
9
10
|
# render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…")
|
|
10
11
|
# render(Studio::ButtonComponent.new(label: "New")) { |b| b.with_icon { plus_svg } }
|
|
11
12
|
#
|
|
@@ -18,7 +19,12 @@ module Studio
|
|
|
18
19
|
# secondary btn-secondary the success role, or the app's retune
|
|
19
20
|
# quiet btn-neutral a second action: Cancel, Back, Next
|
|
20
21
|
# danger btn-danger a destructive action
|
|
21
|
-
#
|
|
22
|
+
# outline btn-outline a bordered, unfilled action on the
|
|
23
|
+
# primary colour; it fills on hover
|
|
24
|
+
# size sm btn-sm · md btn-md · lg btn-lg
|
|
25
|
+
#
|
|
26
|
+
# md's label sits on the type scale at body size (btn-md, 1x --type-base); a
|
|
27
|
+
# bare .btn keeps text-sm, so a host's hand-built buttons do not move.
|
|
22
28
|
#
|
|
23
29
|
# Its public surface, which a release changes only with a Breaking line in the
|
|
24
30
|
# changelog (docs/FRONT_END_STANDARD.md, "A primitive's public surface"):
|
|
@@ -36,7 +42,9 @@ module Studio
|
|
|
36
42
|
# always carries the pending loader, hidden, so a Turbo form that marks itself
|
|
37
43
|
# aria-busy shows it with no script here. The icon is the idle content and
|
|
38
44
|
# gives way to the loader; busy_label: words replace the label while busy.
|
|
39
|
-
# A link cannot be busy, so loading: with href: is refused.
|
|
45
|
+
# A link cannot be busy, so loading: with href: is refused. A busy FORM hides
|
|
46
|
+
# the icon on every button inside it, not just the submit (engine-motion.css,
|
|
47
|
+
# :where(form, button)[aria-busy] [data-loader-idle]); README, "UI Primitives".
|
|
40
48
|
#
|
|
41
49
|
# Every class string is written out whole: Tailwind finds utilities by scanning
|
|
42
50
|
# source text, and engine.css adds app/components to every host's scan.
|
|
@@ -53,12 +61,13 @@ module Studio
|
|
|
53
61
|
primary: "btn-primary",
|
|
54
62
|
secondary: "btn-secondary",
|
|
55
63
|
quiet: "btn-neutral",
|
|
56
|
-
danger: "btn-danger"
|
|
64
|
+
danger: "btn-danger",
|
|
65
|
+
outline: "btn-outline"
|
|
57
66
|
}.freeze
|
|
58
67
|
|
|
59
68
|
SIZES = {
|
|
60
69
|
sm: "btn-sm",
|
|
61
|
-
md:
|
|
70
|
+
md: "btn-md",
|
|
62
71
|
lg: "btn-lg"
|
|
63
72
|
}.freeze
|
|
64
73
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
<%= tag.div(**wrapper_attributes) do %>
|
|
2
2
|
<%= tag.table(**table_attributes) do %>
|
|
3
|
+
<% if caption %><caption class="<%= Studio::TableComponent::CAPTION %>"><%= caption %></caption><% end %>
|
|
3
4
|
<thead class="<%= Studio::TableComponent::HEAD %>">
|
|
4
5
|
<tr>
|
|
5
6
|
<% columns.each do |column| %>
|
|
@@ -29,4 +30,4 @@
|
|
|
29
30
|
<% end %>
|
|
30
31
|
</tbody>
|
|
31
32
|
<% end %>
|
|
32
|
-
|
|
33
|
+
<% end %>
|
|
@@ -12,7 +12,8 @@ module Studio
|
|
|
12
12
|
# { key: :created_at, label: "Joined", align: :right, cell: ->(user) { l(user.created_at.to_date) } }
|
|
13
13
|
# ],
|
|
14
14
|
# sort: params[:sort], direction: params[:direction],
|
|
15
|
-
# empty: "No users yet."
|
|
15
|
+
# empty: "No users yet.",
|
|
16
|
+
# caption: "Team members"
|
|
16
17
|
# )
|
|
17
18
|
#
|
|
18
19
|
# A column is a Hash: key: (required), label: (required), sortable: (false),
|
|
@@ -32,21 +33,36 @@ module Studio
|
|
|
32
33
|
# (admin_model_table, the emails index): the table keeps its columns and its
|
|
33
34
|
# wrapper scrolls. min_width: sets the width under which it scrolls.
|
|
34
35
|
#
|
|
36
|
+
# A NAME AND A KEYBOARD SCROLL. The scroll wrapper takes tabindex="0", so a
|
|
37
|
+
# keyboard user can focus it and scroll it with the arrow keys (WCAG 2.1.1; axe's
|
|
38
|
+
# scrollable-region-focusable), and it shows a focus ring inside its edge, so a
|
|
39
|
+
# card's overflow-hidden cannot clip it.
|
|
40
|
+
# caption: renders a <caption> (visually hidden: the page's own heading is the
|
|
41
|
+
# visible title) and aria_label: labels the table without one. Either one also
|
|
42
|
+
# makes the wrapper a named role="region", so a screen reader announces what it
|
|
43
|
+
# entered. With neither, the wrapper stays focusable but takes no role: a
|
|
44
|
+
# region with no name is noise. Pass one.
|
|
45
|
+
#
|
|
35
46
|
# Its public surface, which a release changes only with a Breaking line in the
|
|
36
47
|
# changelog (docs/FRONT_END_STANDARD.md, "A primitive's public surface"):
|
|
37
48
|
# inputs rows:, columns:, id:, sort:, direction:, sort_url:, sort_param:,
|
|
38
|
-
# direction_param:, empty:, min_width:, sticky_header
|
|
49
|
+
# direction_param:, empty:, min_width:, sticky_header:,
|
|
50
|
+
# caption:, aria_label:
|
|
39
51
|
# data-* data-table on the wrapper; data-table-sort-key on a sortable
|
|
40
52
|
# header; data-table-empty on the empty row; data-sticky-table-header
|
|
41
53
|
# on the table when sticky_header: (studio/sticky_table_header)
|
|
42
|
-
# aria-* aria-sort on the sorted header
|
|
54
|
+
# aria-* aria-sort on the sorted header; aria-label on the table
|
|
55
|
+
# (aria_label:) and on the wrapper, role="region" (caption: or
|
|
56
|
+
# aria_label:)
|
|
57
|
+
# tabindex tabindex="0" on the wrapper
|
|
43
58
|
#
|
|
44
59
|
# Every class string is written out whole: Tailwind finds utilities by scanning
|
|
45
60
|
# source text, and engine.css adds app/components to every host's scan.
|
|
46
61
|
class TableComponent < ViewComponent::Base
|
|
47
62
|
strip_trailing_whitespace
|
|
48
63
|
|
|
49
|
-
WRAPPER = "overflow-x-auto"
|
|
64
|
+
WRAPPER = "overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
|
65
|
+
CAPTION = "sr-only"
|
|
50
66
|
TABLE = "w-full type-body"
|
|
51
67
|
HEAD = "bg-surface-alt text-muted type-fine uppercase tracking-wide"
|
|
52
68
|
BODY = "divide-y divide-subtle"
|
|
@@ -71,7 +87,8 @@ module Studio
|
|
|
71
87
|
attr_reader :rows, :columns, :empty
|
|
72
88
|
|
|
73
89
|
def initialize(rows:, columns:, id: nil, sort: nil, direction: nil, sort_url: nil, sort_param: "sort",
|
|
74
|
-
direction_param: "direction", empty: "Nothing here yet.", min_width: nil, sticky_header: false
|
|
90
|
+
direction_param: "direction", empty: "Nothing here yet.", min_width: nil, sticky_header: false,
|
|
91
|
+
caption: nil, aria_label: nil)
|
|
75
92
|
super()
|
|
76
93
|
raise ArgumentError, "Studio::TableComponent needs at least one column" if columns.blank?
|
|
77
94
|
|
|
@@ -86,12 +103,29 @@ module Studio
|
|
|
86
103
|
@empty = empty
|
|
87
104
|
@min_width = min_width
|
|
88
105
|
@sticky_header = sticky_header ? true : false
|
|
106
|
+
@caption = caption.presence
|
|
107
|
+
@aria_label = aria_label.presence
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
attr_reader :caption
|
|
111
|
+
|
|
112
|
+
# The name the wrapper's region announces: the caption, else aria_label:.
|
|
113
|
+
def accessible_name = @caption || @aria_label
|
|
114
|
+
|
|
115
|
+
def wrapper_attributes
|
|
116
|
+
attrs = { class: WRAPPER, tabindex: "0", data: { table: "" } }
|
|
117
|
+
if accessible_name
|
|
118
|
+
attrs[:role] = "region"
|
|
119
|
+
attrs[:aria] = { label: accessible_name }
|
|
120
|
+
end
|
|
121
|
+
attrs
|
|
89
122
|
end
|
|
90
123
|
|
|
91
124
|
def table_attributes
|
|
92
125
|
attrs = { class: TABLE, id: @id }
|
|
93
126
|
attrs[:style] = "min-width: #{@min_width.to_i}px" if @min_width
|
|
94
127
|
attrs[:data] = { sticky_table_header: "" } if @sticky_header
|
|
128
|
+
attrs[:aria] = { label: @aria_label } if @aria_label && !@caption
|
|
95
129
|
attrs.compact
|
|
96
130
|
end
|
|
97
131
|
|
|
@@ -22,7 +22,14 @@ module Studio
|
|
|
22
22
|
# so it is private: Rails' default for a dynamic GET (max-age=0, private,
|
|
23
23
|
# must-revalidate, with an ETag) is kept, and Vary: Cookie is added so no
|
|
24
24
|
# cache answers one session with another's fragment. The browser holds the
|
|
25
|
-
# fragment
|
|
25
|
+
# fragment across Turbo visits and drops it when the host's session marker
|
|
26
|
+
# changes (Studio::Modals.session_marker, studio/modal_host).
|
|
27
|
+
#
|
|
28
|
+
# NO SCRIPT. studio/modal_host refuses a fragment that carries a <script>.
|
|
29
|
+
# The browser says nothing in production, so the refusal is recorded here,
|
|
30
|
+
# as an ErrorLog row, where it is seen. The fragment is still sent: the
|
|
31
|
+
# browser's refusal (with its reason on the card in development) is the
|
|
32
|
+
# behaviour; this is the record.
|
|
26
33
|
class ModalsController < ::ApplicationController
|
|
27
34
|
skip_before_action :require_authentication, raise: false
|
|
28
35
|
|
|
@@ -37,10 +44,34 @@ module Studio
|
|
|
37
44
|
# the host's error page for the browser to mistake for a fragment.
|
|
38
45
|
response.headers["X-Studio-Modal"] = entry.id
|
|
39
46
|
response.headers["Vary"] = [response.headers["Vary"], "Cookie"].compact.join(", ")
|
|
47
|
+
record_script_fragment(entry) if response.body.to_s.match?(SCRIPT_TAG)
|
|
40
48
|
end
|
|
41
49
|
|
|
42
50
|
private
|
|
43
51
|
|
|
52
|
+
SCRIPT_TAG = %r{<script[\s>/]}i
|
|
53
|
+
|
|
54
|
+
# Ids already recorded in this process: one row per bad modal, not one per
|
|
55
|
+
# request (a prefetched bad fragment would otherwise write a row on every
|
|
56
|
+
# page view).
|
|
57
|
+
RECORDED_SCRIPT_IDS = Set.new
|
|
58
|
+
RECORDED_SCRIPT_LOCK = Mutex.new
|
|
59
|
+
|
|
60
|
+
def self.forget_recorded_scripts! = RECORDED_SCRIPT_LOCK.synchronize { RECORDED_SCRIPT_IDS.clear }
|
|
61
|
+
|
|
62
|
+
# A best-effort record: logging never breaks the answer.
|
|
63
|
+
def record_script_fragment(entry)
|
|
64
|
+
return unless defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
65
|
+
return unless RECORDED_SCRIPT_LOCK.synchronize { RECORDED_SCRIPT_IDS.add?(entry.id) }
|
|
66
|
+
|
|
67
|
+
error = RuntimeError.new("Studio.modals #{entry.id}: #{entry.partial} renders a <script>; " \
|
|
68
|
+
"studio/modal_host refuses it. Move the behaviour to a module or a Stimulus controller.")
|
|
69
|
+
error.set_backtrace(caller)
|
|
70
|
+
::ErrorLog.capture!(error)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
44
75
|
def studio_modal_viewer_signed_in?
|
|
45
76
|
respond_to?(:logged_in?, true) && logged_in?
|
|
46
77
|
end
|
|
@@ -306,15 +306,50 @@ function focusTrap(env, held) {
|
|
|
306
306
|
// 'error' (the host's error card) and forgets the fetch, so the next open
|
|
307
307
|
// retries. So for the first LOAD_DELAY_MS of a first open, isOpen(id) is false.
|
|
308
308
|
//
|
|
309
|
-
// THE
|
|
310
|
-
//
|
|
309
|
+
// THE TIMEOUT. A fetch that has not answered in cfg.timeout_ms (the server's
|
|
310
|
+
// Studio.modals.fetch_timeout_ms, else DEFAULT_FETCH_TIMEOUT_MS) is aborted and
|
|
311
|
+
// shows the error card. Its Try again (retryRemote) re-runs the fetch with the
|
|
312
|
+
// card in place; its Close calls close() directly, so a card opened
|
|
313
|
+
// dismissible: false still has a way out.
|
|
314
|
+
//
|
|
315
|
+
// THE CACHE: fragments by id, beside the store, not on it, keyed on the
|
|
316
|
+
// session. A Turbo visit brings a fresh host without the inserted templates
|
|
311
317
|
// (or a snapshot clone with them), so insertion checks the host every time and
|
|
312
318
|
// inserts only what is missing; that check is what keeps a Turbo visit from
|
|
313
319
|
// inserting a modal twice. A fetch that resolves after a Turbo visit replaced
|
|
314
320
|
// the body (doc.body changed) opens nothing on the new page.
|
|
321
|
+
//
|
|
322
|
+
// THE SESSION KEY. A fragment can carry the viewer's data and a form's CSRF
|
|
323
|
+
// token, and a Turbo visit across sign-in, sign-out or a user switch keeps the
|
|
324
|
+
// document, and with it this cache. So the host advertises cfg.session, a
|
|
325
|
+
// digest of the session id and the signed-in user id (Studio::Modals.
|
|
326
|
+
// session_marker), and every open and every turbo:load compares it: a change
|
|
327
|
+
// drops every cached fragment, every fetch in flight, and every template
|
|
328
|
+
// already inserted on this page, except the template of a modal open now,
|
|
329
|
+
// which goes when it closes (a card is never emptied in place).
|
|
330
|
+
// studio/session's session:changed drops them too, but only on a real
|
|
331
|
+
// identity change (sessionIdentityChange), never on the first seating or a
|
|
332
|
+
// stale -> authenticated revalidation. Why that signal: the session id is what
|
|
333
|
+
// reset_session rotates, and the CSRF secret rotates with it, so a cached form
|
|
334
|
+
// token goes stale exactly when it changes; the user id catches a sign-in
|
|
335
|
+
// that keeps the session. The page's csrf-token meta would not do: it is
|
|
336
|
+
// masked afresh on every render, so it would empty the cache on every visit.
|
|
337
|
+
//
|
|
338
|
+
// THE SCRIPT RULE. A fragment containing a <script> is refused, never
|
|
339
|
+
// inserted: an inserted script would run (or not) by accident of how Alpine
|
|
340
|
+
// clones the template, and it carries no CSP nonce. It shows the error card;
|
|
341
|
+
// with cfg.debug (development and test) the card names the reason. There is
|
|
342
|
+
// no console output in either case: the endpoint records it server-side in an
|
|
343
|
+
// ErrorLog row (Studio::ModalsController).
|
|
344
|
+
//
|
|
345
|
+
// PREFETCH runs when the browser idles after the first load AND after every
|
|
346
|
+
// Turbo visit (turbo:load), so a page reached by Turbo prefetches what it
|
|
347
|
+
// flags, for the viewer it was rendered for.
|
|
315
348
|
|
|
316
349
|
export const REMOTE_MODAL_ATTR = 'data-studio-remote-modal'
|
|
317
350
|
export const LOAD_DELAY_MS = 150
|
|
351
|
+
export const DEFAULT_FETCH_TIMEOUT_MS = 8000
|
|
352
|
+
export const REMOTE_ERROR_TEXT = 'Check your connection and try again.'
|
|
318
353
|
|
|
319
354
|
// The host template advertising on-demand modals, if this page has one.
|
|
320
355
|
export function remoteHost(doc) {
|
|
@@ -329,6 +364,8 @@ export function remoteConfig(doc) {
|
|
|
329
364
|
var cfg = JSON.parse(el.getAttribute('data-modal-host-remote-value'))
|
|
330
365
|
if (!cfg || typeof cfg.url !== 'string' || !Array.isArray(cfg.ids)) return null
|
|
331
366
|
if (!Array.isArray(cfg.prefetch)) cfg.prefetch = []
|
|
367
|
+
if (!(cfg.timeout_ms > 0)) cfg.timeout_ms = DEFAULT_FETCH_TIMEOUT_MS
|
|
368
|
+
if (typeof cfg.session !== 'string') cfg.session = ''
|
|
332
369
|
return cfg
|
|
333
370
|
} catch (e) {
|
|
334
371
|
return null
|
|
@@ -381,18 +418,61 @@ export function remoteModalUrl(cfg, id) {
|
|
|
381
418
|
return cfg.url.replace('__id__', encodeURIComponent(id))
|
|
382
419
|
}
|
|
383
420
|
|
|
421
|
+
// A refused fragment's error. Its message is shown only with cfg.debug.
|
|
422
|
+
function remoteError(message) {
|
|
423
|
+
var err = new Error(message)
|
|
424
|
+
err.studioModal = true
|
|
425
|
+
return err
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// True when the markup carries a <script> element. A text match, not a parse:
|
|
429
|
+
// the fragment is server-rendered, so this guards an app's own mistake, and a
|
|
430
|
+
// false positive (the word in an attribute) errs on the safe side.
|
|
431
|
+
export function fragmentHasScript(markup) {
|
|
432
|
+
return /<script[\s>\/]/i.test(String(markup))
|
|
433
|
+
}
|
|
434
|
+
|
|
384
435
|
// One fetch. Rejects unless the answer is the fragment the endpoint sends
|
|
385
436
|
// (X-Studio-Modal names the id): a host filter's redirect to a full page, or a
|
|
386
|
-
// 401, is an error, never a modal.
|
|
437
|
+
// 401, is an error, never a modal. Rejects too when nothing answers inside
|
|
438
|
+
// cfg.timeout_ms (the request is aborted where the browser can), and when the
|
|
439
|
+
// fragment carries a <script>.
|
|
387
440
|
export function fetchRemoteModal(win, cfg, id) {
|
|
388
|
-
|
|
441
|
+
var ms = cfg.timeout_ms > 0 ? cfg.timeout_ms : DEFAULT_FETCH_TIMEOUT_MS
|
|
442
|
+
var controller = typeof win.AbortController === 'function' ? new win.AbortController() : null
|
|
443
|
+
var init = {
|
|
389
444
|
credentials: 'same-origin',
|
|
390
445
|
headers: { 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest' }
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
|
|
446
|
+
}
|
|
447
|
+
if (controller) init.signal = controller.signal
|
|
448
|
+
|
|
449
|
+
return new Promise(function (resolve, reject) {
|
|
450
|
+
var settled = false
|
|
451
|
+
var timer = win.setTimeout(function () {
|
|
452
|
+
if (settled) return
|
|
453
|
+
settled = true
|
|
454
|
+
if (controller) { try { controller.abort() } catch (e) {} }
|
|
455
|
+
reject(remoteError('studio/modal_host: ' + id + ' did not answer in ' + ms + 'ms'))
|
|
456
|
+
}, ms)
|
|
457
|
+
function finish(fn, value) {
|
|
458
|
+
if (settled) return
|
|
459
|
+
settled = true
|
|
460
|
+
if (win.clearTimeout) win.clearTimeout(timer)
|
|
461
|
+
fn(value)
|
|
394
462
|
}
|
|
395
|
-
|
|
463
|
+
|
|
464
|
+
win.fetch(remoteModalUrl(cfg, id), init).then(function (res) {
|
|
465
|
+
if (!res.ok || !res.headers || res.headers.get('X-Studio-Modal') !== id) {
|
|
466
|
+
throw remoteError('studio/modal_host: ' + id + ' answered ' + res.status)
|
|
467
|
+
}
|
|
468
|
+
return res.text()
|
|
469
|
+
}).then(function (markup) {
|
|
470
|
+
if (fragmentHasScript(markup)) {
|
|
471
|
+
throw remoteError('studio/modal_host: ' + id + ' was refused: its fragment carries a <script>. ' +
|
|
472
|
+
'Move the behaviour to a module or a Stimulus controller.')
|
|
473
|
+
}
|
|
474
|
+
finish(resolve, markup)
|
|
475
|
+
}).catch(function (err) { finish(reject, err) })
|
|
396
476
|
})
|
|
397
477
|
}
|
|
398
478
|
|
|
@@ -402,21 +482,142 @@ export function whenIdle(win, fn) {
|
|
|
402
482
|
else win.setTimeout(fn, 1500)
|
|
403
483
|
}
|
|
404
484
|
|
|
405
|
-
//
|
|
406
|
-
|
|
485
|
+
// Removes the inserted registrations from this page's cards (the session
|
|
486
|
+
// changed, so what they carry is stale), except those whose id is in keep:
|
|
487
|
+
// the modals on the stack now, which must never be emptied in place. Answers
|
|
488
|
+
// the ids it kept.
|
|
489
|
+
function removeRemoteModals(doc, keep) {
|
|
490
|
+
keep = keep || []
|
|
491
|
+
var kept = []
|
|
492
|
+
var cards = remoteCards(doc)
|
|
493
|
+
for (var i = 0; i < cards.length; i++) {
|
|
494
|
+
var kids = Array.prototype.slice.call(cards[i].children || [])
|
|
495
|
+
for (var k = 0; k < kids.length; k++) {
|
|
496
|
+
var kidId = kids[k].getAttribute ? kids[k].getAttribute(REMOTE_MODAL_ATTR) : null
|
|
497
|
+
if (kidId !== null && keep.indexOf(kidId) !== -1) {
|
|
498
|
+
if (kept.indexOf(kidId) === -1) kept.push(kidId)
|
|
499
|
+
continue
|
|
500
|
+
}
|
|
501
|
+
if (kidId !== null) {
|
|
502
|
+
if (typeof cards[i].removeChild === 'function') cards[i].removeChild(kids[k])
|
|
503
|
+
else if (typeof kids[k].remove === 'function') kids[k].remove()
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return kept
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// THE SETTLED STATES of studio/session (docs/SESSION_DRIFT.md). A settled
|
|
511
|
+
// event whose fingerprint (user + bound identities) differs from the last is an
|
|
512
|
+
// identity change; rehydrated is one ("possibly as someone new"); stale is not.
|
|
513
|
+
var SETTLED_SESSION_STATES = ['anonymous', 'authenticated', 'signed_out', 'changed', 'rehydrated']
|
|
514
|
+
|
|
515
|
+
// Is this session:changed detail a REAL change of who is viewing? studio/session
|
|
516
|
+
// sends the event on every transition, including the first seating on page
|
|
517
|
+
// load (previous null) and stale -> authenticated when a hidden tab returns,
|
|
518
|
+
// and neither may throw the cache away. lastIdentity is the identity string
|
|
519
|
+
// the previous settled event carried (null before the first). Answers
|
|
520
|
+
// { real, identity }: identity is the string to remember.
|
|
521
|
+
export function sessionIdentityChange(detail, lastIdentity) {
|
|
522
|
+
var d = detail || {}
|
|
523
|
+
var snap = d.snapshot || {}
|
|
524
|
+
var identity = snap.state === 'anonymous' || snap.state === 'signed_out' || d.state === 'anonymous' || d.state === 'signed_out'
|
|
525
|
+
? 'anonymous'
|
|
526
|
+
: (snap.fingerprint || JSON.stringify(snap.identities || {}))
|
|
527
|
+
// First seating, or the store has no state yet: remember, never drop.
|
|
528
|
+
if (d.previous === null || d.previous === undefined || !d.state) return { real: false, identity: identity }
|
|
529
|
+
// Someone else observed (changed) or the server reports nobody (signed_out).
|
|
530
|
+
if (d.state === 'changed' || (d.state === 'signed_out' && d.previous !== 'signed_out')) {
|
|
531
|
+
return { real: true, identity: identity }
|
|
532
|
+
}
|
|
533
|
+
// A settled state with a different identity than the last one seen.
|
|
534
|
+
if (SETTLED_SESSION_STATES.indexOf(d.state) !== -1 && lastIdentity !== null && identity !== lastIdentity) {
|
|
535
|
+
return { real: true, identity: identity }
|
|
536
|
+
}
|
|
537
|
+
// stale, or a return to the same identity: revalidation.
|
|
538
|
+
return { real: false, identity: SETTLED_SESSION_STATES.indexOf(d.state) !== -1 ? identity : lastIdentity }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The loader one shared store keeps in its closure. openIds() answers the ids
|
|
542
|
+
// on the store's stack now: their templates survive a drop until they close.
|
|
543
|
+
export function createRemoteModals(env, openIds) {
|
|
407
544
|
var doc = env.doc
|
|
408
545
|
var win = env.win
|
|
546
|
+
var openNowIds = typeof openIds === 'function' ? openIds : function () { return [] }
|
|
547
|
+
var staleOpen = [] // ids whose stale template was kept because they were open
|
|
409
548
|
var pending = {} // id -> Promise<markup>, while in flight or resolved
|
|
410
549
|
var ready = {} // id -> markup, once resolved
|
|
550
|
+
var session = null // the cfg.session the cache was filled under
|
|
551
|
+
var generation = 0 // bumped by a drop, so a fetch from before it caches nothing
|
|
552
|
+
|
|
553
|
+
// Drops the cache at once. The templates go too, except those of modals on
|
|
554
|
+
// the stack now: a card is never emptied in place (a dismissible: false one
|
|
555
|
+
// would leave the viewer with no way out). Those go when they close (flush).
|
|
556
|
+
function drop() {
|
|
557
|
+
pending = {}
|
|
558
|
+
ready = {}
|
|
559
|
+
generation++
|
|
560
|
+
staleOpen = removeRemoteModals(doc, openNowIds())
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Removes each kept stale template whose modal has left the stack. Called
|
|
564
|
+
// whenever the stack changes.
|
|
565
|
+
function flush() {
|
|
566
|
+
if (staleOpen.length === 0) return
|
|
567
|
+
var open = openNowIds()
|
|
568
|
+
var keep = staleOpen.filter(function (id) { return open.indexOf(id) !== -1 })
|
|
569
|
+
if (keep.length === staleOpen.length) return
|
|
570
|
+
// Only the kept ids are stale; every other inserted template is fresh.
|
|
571
|
+
var gone = staleOpen.filter(function (id) { return keep.indexOf(id) === -1 })
|
|
572
|
+
var cards = remoteCards(doc)
|
|
573
|
+
for (var i = 0; i < cards.length; i++) {
|
|
574
|
+
var kids = Array.prototype.slice.call(cards[i].children || [])
|
|
575
|
+
for (var k = 0; k < kids.length; k++) {
|
|
576
|
+
var kidId = kids[k].getAttribute ? kids[k].getAttribute(REMOTE_MODAL_ATTR) : null
|
|
577
|
+
if (kidId !== null && gone.indexOf(kidId) !== -1) {
|
|
578
|
+
if (typeof cards[i].removeChild === 'function') cards[i].removeChild(kids[k])
|
|
579
|
+
else if (typeof kids[k].remove === 'function') kids[k].remove()
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
staleOpen = keep
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Drops the cache when the advertised session differs from the one it was
|
|
587
|
+
// filled under. The first config seen only sets the key.
|
|
588
|
+
function sync(cfg) {
|
|
589
|
+
if (!cfg) return
|
|
590
|
+
if (session === null) { session = cfg.session; return }
|
|
591
|
+
if (cfg.session === session) return
|
|
592
|
+
session = cfg.session
|
|
593
|
+
drop()
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// studio/session's session:changed drops the cache too, for a change that
|
|
597
|
+
// happens without a page render to carry a new marker, but ONLY on a real
|
|
598
|
+
// identity change (sessionIdentityChange): the event also fires on the
|
|
599
|
+
// first seating at every load and on stale -> authenticated revalidation,
|
|
600
|
+
// which must keep a finished prefetch and an open card. The host's marker
|
|
601
|
+
// (sync, on every open and turbo:load) stays the primary signal.
|
|
602
|
+
var lastIdentity = null
|
|
603
|
+
if (doc && typeof doc.addEventListener === 'function') {
|
|
604
|
+
doc.addEventListener('session:changed', function (event) {
|
|
605
|
+
var verdict = sessionIdentityChange(event && event.detail, lastIdentity)
|
|
606
|
+
lastIdentity = verdict.identity
|
|
607
|
+
if (verdict.real) drop()
|
|
608
|
+
})
|
|
609
|
+
}
|
|
411
610
|
|
|
412
611
|
function load(cfg, id) {
|
|
612
|
+
sync(cfg)
|
|
413
613
|
if (ready[id] !== undefined) return Promise.resolve(ready[id])
|
|
414
614
|
if (!pending[id]) {
|
|
415
|
-
|
|
416
|
-
|
|
615
|
+
var gen = generation
|
|
616
|
+
var mine = pending[id] = fetchRemoteModal(win, cfg, id).then(function (markup) {
|
|
617
|
+
if (gen === generation) ready[id] = markup
|
|
417
618
|
return markup
|
|
418
619
|
}, function (err) {
|
|
419
|
-
delete pending[id]
|
|
620
|
+
if (pending[id] === mine) delete pending[id]
|
|
420
621
|
throw err
|
|
421
622
|
})
|
|
422
623
|
}
|
|
@@ -424,10 +625,11 @@ export function createRemoteModals(env) {
|
|
|
424
625
|
}
|
|
425
626
|
|
|
426
627
|
return {
|
|
427
|
-
// The config when id is advertised here, else null.
|
|
628
|
+
// The config when id is advertised here, else null. Checks the session.
|
|
428
629
|
handles: function (id) {
|
|
429
630
|
if (typeof id !== 'string') return null
|
|
430
631
|
var cfg = remoteConfig(doc)
|
|
632
|
+
sync(cfg)
|
|
431
633
|
return (cfg && cfg.ids.indexOf(id) !== -1) ? cfg : null
|
|
432
634
|
},
|
|
433
635
|
load: load,
|
|
@@ -437,8 +639,10 @@ export function createRemoteModals(env) {
|
|
|
437
639
|
return ready[id] !== undefined && insertRemoteModal(doc, id, ready[id])
|
|
438
640
|
},
|
|
439
641
|
insert: function (id, markup) { return insertRemoteModal(doc, id, markup) },
|
|
642
|
+
flush: flush,
|
|
440
643
|
prefetch: function () {
|
|
441
644
|
var cfg = remoteConfig(doc)
|
|
645
|
+
sync(cfg)
|
|
442
646
|
if (!cfg || typeof win.fetch !== 'function') return
|
|
443
647
|
cfg.prefetch.forEach(function (id) {
|
|
444
648
|
// A failed prefetch is retried by the open that needs it.
|
|
@@ -460,7 +664,19 @@ export function createModalStore(env) {
|
|
|
460
664
|
// id -> 'loading' | 'error' for an on-demand modal; read by the host's
|
|
461
665
|
// loading and error cards and by each fetched registration's x-if.
|
|
462
666
|
store._remote = {}
|
|
463
|
-
|
|
667
|
+
// id -> the reason the last load failed, shown on the error card only when
|
|
668
|
+
// the host advertises debug (development and test).
|
|
669
|
+
store._remoteReason = {}
|
|
670
|
+
var remote = createRemoteModals(env, function () {
|
|
671
|
+
return store.stack.map(function (entry) { return entry.id })
|
|
672
|
+
})
|
|
673
|
+
// Every stack change (open, close, swap, sweep) goes through _sync, so a
|
|
674
|
+
// stale template kept for an open card is removed once that card leaves.
|
|
675
|
+
var baseSync = store._sync
|
|
676
|
+
store._sync = function () {
|
|
677
|
+
baseSync.call(this)
|
|
678
|
+
remote.flush()
|
|
679
|
+
}
|
|
464
680
|
|
|
465
681
|
// open(id, props, opts)
|
|
466
682
|
// opts.replace: swap the top entry. With a visible card, a directional
|
|
@@ -470,52 +686,95 @@ export function createModalStore(env) {
|
|
|
470
686
|
// An on-demand id not yet in the host loads first (openRemote below).
|
|
471
687
|
store.open = function (id, props, opts) {
|
|
472
688
|
var cfg = remote.handles(id)
|
|
473
|
-
if (cfg && !remote.insertIfReady(id)) return openRemote(this, cfg, id, props, opts)
|
|
689
|
+
if (cfg && !remote.insertIfReady(id)) return openRemote(this, cfg, id, props, opts, false)
|
|
474
690
|
return openNow.call(this, id, props, opts)
|
|
475
691
|
}
|
|
476
692
|
|
|
477
|
-
// The
|
|
478
|
-
//
|
|
479
|
-
function
|
|
693
|
+
// The error card's Try again: re-runs the current entry's fetch with the
|
|
694
|
+
// entry left where it is, showing the loading card meanwhile.
|
|
695
|
+
store.retryRemote = function () {
|
|
696
|
+
var entry = this.current()
|
|
697
|
+
if (!entry || this._remote[entry.id] !== 'error') return
|
|
698
|
+
var cfg = remote.handles(entry.id)
|
|
699
|
+
if (!cfg) return
|
|
700
|
+
// Try again unmounts the focused button, so focus goes back to the
|
|
701
|
+
// backdrop each time the card's content changes (here and in openRemote).
|
|
702
|
+
if (remote.insertIfReady(entry.id)) { delete this._remote[entry.id]; this.refocus(); return }
|
|
703
|
+
openRemote(this, cfg, entry.id, entry.props, null, true)
|
|
704
|
+
this.refocus()
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// The error card's message: the generic line, or with cfg.debug the reason.
|
|
708
|
+
store.remoteErrorText = function () {
|
|
709
|
+
var entry = this.current()
|
|
710
|
+
var reason = entry && this._remoteReason[entry.id]
|
|
711
|
+
return reason || REMOTE_ERROR_TEXT
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// The first open of an on-demand id (or a retry, when inPlace: the entry is
|
|
715
|
+
// already on the stack). Never throws and never leaves a promise unhandled:
|
|
716
|
+
// every outcome is the modal, the loading card, or the error card.
|
|
717
|
+
function openRemote(self, cfg, id, props, opts, inPlace) {
|
|
480
718
|
var body = env.doc.body
|
|
481
|
-
var shown =
|
|
719
|
+
var shown = !!inPlace
|
|
482
720
|
var hold = null
|
|
483
721
|
var done = false
|
|
722
|
+
var timer = null
|
|
484
723
|
if (self._remote[id] === 'error') delete self._remote[id]
|
|
724
|
+
delete self._remoteReason[id]
|
|
725
|
+
var minLoad = function () { return (win.StudioModals && win.StudioModals.MIN_LOAD_MS) || MIN_LOAD_MS }
|
|
485
726
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
shown = true
|
|
489
|
-
hold = holdAtLeast((win.StudioModals && win.StudioModals.MIN_LOAD_MS) || MIN_LOAD_MS)
|
|
727
|
+
if (inPlace) {
|
|
728
|
+
hold = holdAtLeast(minLoad())
|
|
490
729
|
self._remote[id] = 'loading'
|
|
491
|
-
|
|
492
|
-
|
|
730
|
+
} else {
|
|
731
|
+
timer = win.setTimeout(function () {
|
|
732
|
+
if (done || env.doc.body !== body) return
|
|
733
|
+
shown = true
|
|
734
|
+
hold = holdAtLeast(minLoad())
|
|
735
|
+
self._remote[id] = 'loading'
|
|
736
|
+
openNow.call(self, id, props, opts)
|
|
737
|
+
}, LOAD_DELAY_MS)
|
|
738
|
+
}
|
|
493
739
|
|
|
494
|
-
function
|
|
740
|
+
function stopTimer() { if (timer !== null && win.clearTimeout) win.clearTimeout(timer) }
|
|
741
|
+
// Only while this modal is on top: a late answer must not pull focus off another card.
|
|
742
|
+
function refocusIfTop() { var top = self.current(); if (top && top.id === id) self.refocus() }
|
|
743
|
+
|
|
744
|
+
function fail(err) {
|
|
495
745
|
if (done) return
|
|
496
746
|
done = true
|
|
497
|
-
|
|
747
|
+
stopTimer()
|
|
498
748
|
if (env.doc.body !== body) return
|
|
749
|
+
if (cfg.debug && err && err.studioModal) self._remoteReason[id] = err.message
|
|
499
750
|
self._remote[id] = 'error'
|
|
500
751
|
if (!shown) openNow.call(self, id, props, opts)
|
|
752
|
+
else refocusIfTop()
|
|
501
753
|
}
|
|
502
754
|
|
|
503
755
|
remote.load(cfg, id).then(function (markup) {
|
|
504
756
|
if (done) return
|
|
505
|
-
if (env.doc.body !== body) { done = true;
|
|
757
|
+
if (env.doc.body !== body) { done = true; stopTimer(); return }
|
|
506
758
|
if (!remote.insert(id, markup)) { fail(); return }
|
|
507
759
|
done = true
|
|
508
|
-
|
|
760
|
+
stopTimer()
|
|
509
761
|
if (!shown) { openNow.call(self, id, props, opts); return }
|
|
510
762
|
hold.then(function () {
|
|
511
|
-
if (self._remote[id] === 'loading') delete self._remote[id]
|
|
763
|
+
if (self._remote[id] === 'loading') { delete self._remote[id]; refocusIfTop() }
|
|
512
764
|
})
|
|
513
765
|
}).catch(fail)
|
|
514
766
|
}
|
|
515
767
|
|
|
516
|
-
// Schedules the advertised prefetches
|
|
768
|
+
// Schedules the advertised prefetches when the browser idles: once now, for
|
|
769
|
+
// the first load, and again after every Turbo visit. load() shares one
|
|
770
|
+
// request per id, so the first load's turbo:load adds no second fetch.
|
|
517
771
|
if (env.doc && typeof env.doc.querySelector === 'function') {
|
|
518
772
|
whenIdle(win, function () { remote.prefetch() })
|
|
773
|
+
if (typeof env.doc.addEventListener === 'function') {
|
|
774
|
+
env.doc.addEventListener('turbo:load', function () {
|
|
775
|
+
whenIdle(win, function () { remote.prefetch() })
|
|
776
|
+
})
|
|
777
|
+
}
|
|
519
778
|
}
|
|
520
779
|
|
|
521
780
|
function openNow(id, props, opts) {
|
|
@@ -219,10 +219,19 @@
|
|
|
219
219
|
fetches a registered id the first time it opens and inserts its
|
|
220
220
|
<template x-if> into the card. See the README, "Modals on demand". %>
|
|
221
221
|
<% studio_remote_modals = Studio.modals.any? %>
|
|
222
|
+
<% if studio_remote_modals
|
|
223
|
+
studio_remote_session = respond_to?(:controller) && controller.respond_to?(:session) ? controller.session : nil
|
|
224
|
+
studio_remote_config = Studio.modals.client_config(
|
|
225
|
+
url_template: studio_modal_path(id: "0").delete_suffix("0") + "__id__",
|
|
226
|
+
logged_in: respond_to?(:logged_in?) && logged_in?,
|
|
227
|
+
session: Studio::Modals.session_marker(studio_remote_session),
|
|
228
|
+
debug: defined?(Rails) && Rails.respond_to?(:env) && (Rails.env.development? || Rails.env.test?)
|
|
229
|
+
)
|
|
230
|
+
end %>
|
|
222
231
|
<template x-if="$store.modals.current()"
|
|
223
232
|
data-studio-controller="modal-host"
|
|
224
233
|
data-modal-host-store-value="modals"<% if studio_remote_modals %>
|
|
225
|
-
data-modal-host-remote-value="<%=
|
|
234
|
+
data-modal-host-remote-value="<%= studio_remote_config.to_json %>"<% end %>>
|
|
226
235
|
<%# Backdrop + card use CSS keyframe animations (NOT Alpine
|
|
227
236
|
x-transition), all shipped in the inline style block above. On
|
|
228
237
|
mount, the .modal-backdrop-mount + card mount classes fire fade-in
|
|
@@ -312,22 +321,28 @@
|
|
|
312
321
|
<%# The on-demand states, keyed on $store.modals._remote[id], which only
|
|
313
322
|
studio/modal_host sets and only for a registered id: 'loading' while a
|
|
314
323
|
slow first fetch runs (the processing card, held MIN_LOAD_MS so it
|
|
315
|
-
never flashes
|
|
316
|
-
|
|
324
|
+
never flashes; role="status", so a screen reader hears it politely),
|
|
325
|
+
'error' when the fetch fails, times out (Studio.modals.fetch_timeout_ms)
|
|
326
|
+
or is refused (the error card: Try again re-runs the fetch in place,
|
|
327
|
+
Close closes). Close calls close() directly, so it works on a card
|
|
328
|
+
opened dismissible: false too, which Escape and the backdrop do not
|
|
329
|
+
close. Fetched registrations are appended after these. %>
|
|
317
330
|
<% if studio_remote_modals %>
|
|
318
331
|
<template x-if="$store.modals._remote && $store.modals._remote[$store.modals.current()?.id] === 'loading'">
|
|
319
|
-
<div data-modal-remote-state="loading">
|
|
332
|
+
<div data-modal-remote-state="loading" role="status" aria-live="polite">
|
|
320
333
|
<%= render "studio/modals/blocks/processing_card", title: "Loading…" %>
|
|
321
334
|
</div>
|
|
322
335
|
</template>
|
|
323
336
|
<template x-if="$store.modals._remote && $store.modals._remote[$store.modals.current()?.id] === 'error'">
|
|
324
|
-
<div data-modal-remote-state="error">
|
|
337
|
+
<div data-modal-remote-state="error" @studio-modal-retry="$store.modals.retryRemote()">
|
|
325
338
|
<%= render "studio/modals/blocks/error_card",
|
|
326
339
|
title: "Couldn't open this",
|
|
327
|
-
|
|
340
|
+
message_key: "$store.modals.remoteErrorText()",
|
|
328
341
|
icon_emoji: "⚠️",
|
|
329
|
-
cta_label: "
|
|
330
|
-
|
|
342
|
+
cta_label: "Try again",
|
|
343
|
+
cta_event: "studio-modal-retry",
|
|
344
|
+
secondary_label: "Close",
|
|
345
|
+
secondary_close: true %>
|
|
331
346
|
</div>
|
|
332
347
|
</template>
|
|
333
348
|
<% end %>
|
|
@@ -93,14 +93,15 @@
|
|
|
93
93
|
<h3 class="type-h4 text-heading">Table · sorted by member, a badge cell</h3>
|
|
94
94
|
<div class="card overflow-hidden">
|
|
95
95
|
<%= render Studio::TableComponent.new(rows: people, columns: columns, sort: "name", direction: "asc",
|
|
96
|
-
sort_url: sort_url, min_width: 520) %>
|
|
96
|
+
sort_url: sort_url, min_width: 520, caption: "Team members, sorted by member") %>
|
|
97
97
|
</div>
|
|
98
98
|
</div>
|
|
99
99
|
|
|
100
100
|
<div class="space-y-3" data-specimen="table-empty">
|
|
101
101
|
<h3 class="type-h4 text-heading">Table · no rows</h3>
|
|
102
102
|
<div class="card overflow-hidden">
|
|
103
|
-
<%= render Studio::TableComponent.new(rows: [], columns: columns, sort_url: sort_url, empty: "No members yet."
|
|
103
|
+
<%= render Studio::TableComponent.new(rows: [], columns: columns, sort_url: sort_url, empty: "No members yet.",
|
|
104
|
+
aria_label: "Team members") %>
|
|
104
105
|
</div>
|
|
105
106
|
</div>
|
|
106
107
|
</div>
|
data/lib/studio/modals.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
module Studio
|
|
4
6
|
# The on-demand modal registry: modals the shared host (studio/modals/_host)
|
|
5
7
|
# fetches the first time they open, instead of the layout rendering every
|
|
@@ -22,11 +24,14 @@ module Studio
|
|
|
22
24
|
# (one root element; see the README's "Modal host" rules).
|
|
23
25
|
# auth: :any (default) or :user. A :user modal answers 401 to a
|
|
24
26
|
# signed-out request, and is never prefetched for one.
|
|
25
|
-
# prefetch: true fetches the modal when the browser idles after load
|
|
26
|
-
# modal that should open with no
|
|
27
|
+
# prefetch: true fetches the modal when the browser idles after load and
|
|
28
|
+
# after every Turbo visit, for a modal that should open with no
|
|
29
|
+
# wait (the sign-in card).
|
|
27
30
|
# locals: the partial's locals. A Hash, a Symbol naming a helper the view
|
|
28
31
|
# answers (Turf's existing *_modal_locals helpers), or a callable
|
|
29
|
-
# taking the view. Resolved per request, inside the view
|
|
32
|
+
# taking the view. Resolved per request, inside the view of
|
|
33
|
+
# Studio::ModalsController: helpers and the session are there,
|
|
34
|
+
# the page's instance variables (@contest) are not.
|
|
30
35
|
module Modals
|
|
31
36
|
AUTH_LEVELS = %i[any user].freeze
|
|
32
37
|
|
|
@@ -54,8 +59,23 @@ module Studio
|
|
|
54
59
|
end
|
|
55
60
|
|
|
56
61
|
class Registry
|
|
62
|
+
# How long studio/modal_host waits for a fragment before it gives up and
|
|
63
|
+
# shows the error card (with Try again and Close), in milliseconds.
|
|
64
|
+
DEFAULT_FETCH_TIMEOUT_MS = 8000
|
|
65
|
+
|
|
66
|
+
# Settable from the initializer: Studio.modals.fetch_timeout_ms = 12_000.
|
|
67
|
+
attr_reader :fetch_timeout_ms
|
|
68
|
+
|
|
57
69
|
def initialize
|
|
58
70
|
@entries = {}
|
|
71
|
+
@fetch_timeout_ms = DEFAULT_FETCH_TIMEOUT_MS
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def fetch_timeout_ms=(value)
|
|
75
|
+
ms = Integer(value)
|
|
76
|
+
raise ArgumentError, "Studio.modals.fetch_timeout_ms must be positive, got #{value.inspect}" unless ms.positive?
|
|
77
|
+
|
|
78
|
+
@fetch_timeout_ms = ms
|
|
59
79
|
end
|
|
60
80
|
|
|
61
81
|
def register(id, partial:, auth: :any, prefetch: false, locals: nil)
|
|
@@ -85,16 +105,48 @@ module Studio
|
|
|
85
105
|
# What the host template advertises to studio/modal_host. Every id, so a
|
|
86
106
|
# signed-out open of a :user modal still reaches the endpoint and shows
|
|
87
107
|
# the error card; prefetch only what this viewer may load.
|
|
88
|
-
|
|
108
|
+
#
|
|
109
|
+
# session: the marker studio/modal_host keys its fragment cache on (see
|
|
110
|
+
# Modals.session_marker). debug: true in development and test, where a
|
|
111
|
+
# refused fragment names its reason on the error card.
|
|
112
|
+
def client_config(url_template:, logged_in:, session: nil, debug: false)
|
|
89
113
|
{
|
|
90
114
|
url: url_template,
|
|
91
115
|
ids: ids,
|
|
92
|
-
prefetch: entries.select { |e| e.prefetch && (!e.user_only? || logged_in) }.map(&:id)
|
|
116
|
+
prefetch: entries.select { |e| e.prefetch && (!e.user_only? || logged_in) }.map(&:id),
|
|
117
|
+
timeout_ms: fetch_timeout_ms,
|
|
118
|
+
session: session.to_s,
|
|
119
|
+
debug: debug == true
|
|
93
120
|
}
|
|
94
121
|
end
|
|
95
122
|
end
|
|
96
123
|
end
|
|
97
124
|
|
|
125
|
+
module Modals
|
|
126
|
+
# THE SESSION MARKER. A fetched fragment can carry the viewer's data and a
|
|
127
|
+
# form's CSRF token, and studio/modal_host keeps it for the page's life. A
|
|
128
|
+
# Turbo visit across sign-in, sign-out or a user switch keeps that page, so
|
|
129
|
+
# the browser compares this marker on every open and every turbo:load and
|
|
130
|
+
# drops its cache when it changes.
|
|
131
|
+
#
|
|
132
|
+
# It digests two things the request already has:
|
|
133
|
+
# - the session id, which reset_session rotates (sign-out, and the
|
|
134
|
+
# sign-ins that reset), and with it the session's CSRF secret: a cached
|
|
135
|
+
# form token stops being valid exactly when this changes;
|
|
136
|
+
# - the signed-in user id under Studio.session_key, which a sign-in that
|
|
137
|
+
# does NOT reset the session (an OAuth or wallet sign-in) still changes.
|
|
138
|
+
# Digested so neither value reaches the page. A request with no session
|
|
139
|
+
# (a view rendered outside a controller) answers the digest of nothing:
|
|
140
|
+
# a constant, so the cache simply lives for the page, as before.
|
|
141
|
+
def self.session_marker(session)
|
|
142
|
+
sid = session.respond_to?(:id) ? session.id.to_s : ""
|
|
143
|
+
uid = session.respond_to?(:[]) ? session[Studio.session_key].to_s : ""
|
|
144
|
+
Digest::SHA256.hexdigest("#{sid}|#{uid}")[0, 16]
|
|
145
|
+
rescue StandardError
|
|
146
|
+
Digest::SHA256.hexdigest("|")[0, 16]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
98
150
|
def self.modals
|
|
99
151
|
@modals ||= Modals::Registry.new
|
|
100
152
|
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Studio::SystemTestCase: the one browser-test base every host app inherits.
|
|
4
|
+
#
|
|
5
|
+
# A host's test/application_system_test_case.rb becomes:
|
|
6
|
+
#
|
|
7
|
+
# require "test_helper"
|
|
8
|
+
# require "studio/system_test_case"
|
|
9
|
+
#
|
|
10
|
+
# class ApplicationSystemTestCase < Studio::SystemTestCase
|
|
11
|
+
# driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1000]
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# It owns the harness bugs that were fixed app by app, so a fix ships once, in
|
|
15
|
+
# the gem, to every app. Detail and the customization API: docs/SYSTEM_TESTS.md.
|
|
16
|
+
#
|
|
17
|
+
# lib/studio.rb does NOT require this file. It needs Capybara and
|
|
18
|
+
# selenium-webdriver, which a host bundles in its test group and the gem does
|
|
19
|
+
# not declare. The host requires it from its system-test base only.
|
|
20
|
+
require "action_dispatch/system_test_case"
|
|
21
|
+
require "capybara/selenium/driver"
|
|
22
|
+
|
|
23
|
+
module Studio
|
|
24
|
+
class SystemTestCase < ActionDispatch::SystemTestCase
|
|
25
|
+
# The harness hooks this base runs, by name. A subclass opts out of one by
|
|
26
|
+
# name, with a reason (skip_harness_hook); every other hook still runs.
|
|
27
|
+
HOOKS = {
|
|
28
|
+
reset_window: "setup: resize the window to desktop_size. The browser outlives " \
|
|
29
|
+
"the test, so a resize leaks into the next test (commercial-welding " \
|
|
30
|
+
"went red under one seed).",
|
|
31
|
+
restore_script_timeout: "around evaluate_async_script: give back the script " \
|
|
32
|
+
"timeout. Capybara 3.40.0 sets it to default_max_wait_time " \
|
|
33
|
+
"(2s) and never restores it (voicebox ContrastSystemTest).",
|
|
34
|
+
clear_emulation: "teardown: clear the DevTools device-metrics and emulated-media " \
|
|
35
|
+
"overrides (phone_viewport!, a dark-mode or reduced-motion test), " \
|
|
36
|
+
"so neither leaks into the next test."
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Selenium's stock read budget is Net::HTTP's 60s. A cold Chrome launch on a
|
|
40
|
+
# slow CI runner can pass it, and the run dies with Net::ReadTimeout in
|
|
41
|
+
# whichever test the seed put first (hub PR #2494).
|
|
42
|
+
DEFAULT_READ_TIMEOUT = 180
|
|
43
|
+
DEFAULT_DESKTOP_SIZE = [1400, 1000].freeze
|
|
44
|
+
|
|
45
|
+
# Thread-local switch the restore_script_timeout hook reads. The prepend on
|
|
46
|
+
# Capybara's driver is global; this key makes the opt-out per test class.
|
|
47
|
+
SCRIPT_TIMEOUT_KEY = :studio_system_test_restore_script_timeout
|
|
48
|
+
|
|
49
|
+
# The window every test starts in. Set it through driven_by's screen_size,
|
|
50
|
+
# so the browser's launch size and the reset size are one number.
|
|
51
|
+
class_attribute :desktop_size, instance_accessor: false, default: DEFAULT_DESKTOP_SIZE
|
|
52
|
+
# The phone viewport phone_viewport! and assert_viewport_width default to.
|
|
53
|
+
class_attribute :phone_width, instance_accessor: false, default: 390
|
|
54
|
+
class_attribute :phone_height, instance_accessor: false, default: 844
|
|
55
|
+
# { hook name => reason } for the hooks this class opted out of.
|
|
56
|
+
class_attribute :skipped_harness_hooks, instance_accessor: false, default: {}.freeze
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Rails' driven_by, with two additions. screen_size also becomes
|
|
60
|
+
# desktop_size, the size reset_window restores. A Selenium driver gets a
|
|
61
|
+
# 180s read timeout unless options: names its own :timeout.
|
|
62
|
+
def driven_by(driver = :selenium, using: :headless_chrome, screen_size: DEFAULT_DESKTOP_SIZE,
|
|
63
|
+
options: {}, &capabilities)
|
|
64
|
+
self.desktop_size = Array(screen_size).dup.freeze
|
|
65
|
+
options = { timeout: DEFAULT_READ_TIMEOUT }.merge(options) if driver.to_sym == :selenium
|
|
66
|
+
super(driver, using: using, screen_size: screen_size, options: options, &capabilities)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Opt this class (and its subclasses) out of one harness hook. The name
|
|
70
|
+
# must be in HOOKS and the reason must say why; both are logged once, so a
|
|
71
|
+
# skipped fix is never silent.
|
|
72
|
+
def skip_harness_hook(name, reason:)
|
|
73
|
+
name = name.to_sym
|
|
74
|
+
unless HOOKS.key?(name)
|
|
75
|
+
raise ArgumentError, "unknown harness hook #{name.inspect}; the hooks are #{HOOKS.keys.map(&:inspect).join(', ')}"
|
|
76
|
+
end
|
|
77
|
+
raise ArgumentError, "skip_harness_hook #{name.inspect} needs a reason:" if reason.to_s.strip.empty?
|
|
78
|
+
|
|
79
|
+
self.skipped_harness_hooks = skipped_harness_hooks.merge(name => reason.to_s.strip).freeze
|
|
80
|
+
warn "[Studio::SystemTestCase] #{self.name || inspect} skips the #{name} hook: #{reason.to_s.strip}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def harness_hook?(name)
|
|
84
|
+
!skipped_harness_hooks.key?(name.to_sym)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Read by the driver prepend below. nil (no Studio test running on this
|
|
88
|
+
# thread) means on: the restore is a pure bug fix.
|
|
89
|
+
def restore_script_timeout?
|
|
90
|
+
Thread.current[SCRIPT_TIMEOUT_KEY] != false
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Capybara 3.40.0's evaluate_async_script sets the browser's script timeout
|
|
95
|
+
# to Capybara.default_max_wait_time (2s) and never sets it back. The browser
|
|
96
|
+
# outlives the test, so every later execute_script in the run has 2s, not the
|
|
97
|
+
# driver's 30s. This gives back the timeout it borrowed (voicebox PR #35).
|
|
98
|
+
module RestoresScriptTimeout
|
|
99
|
+
def evaluate_async_script(script, *args)
|
|
100
|
+
return super unless Studio::SystemTestCase.restore_script_timeout?
|
|
101
|
+
|
|
102
|
+
timeouts = browser.manage.timeouts
|
|
103
|
+
previous = timeouts.script_timeout
|
|
104
|
+
super
|
|
105
|
+
ensure
|
|
106
|
+
timeouts.script_timeout = previous if previous
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
Capybara::Selenium::Driver.prepend(RestoresScriptTimeout)
|
|
110
|
+
|
|
111
|
+
driven_by :selenium, using: :headless_chrome, screen_size: DEFAULT_DESKTOP_SIZE
|
|
112
|
+
|
|
113
|
+
# The base's setup runs before any setup a subclass declares, so an app's
|
|
114
|
+
# own setup always starts in the reset window.
|
|
115
|
+
setup do
|
|
116
|
+
Thread.current[SCRIPT_TIMEOUT_KEY] = self.class.harness_hook?(:restore_script_timeout)
|
|
117
|
+
reset_window if self.class.harness_hook?(:reset_window) && browser_window?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Teardown callbacks run in reverse: a subclass's teardown runs first, then
|
|
121
|
+
# this one.
|
|
122
|
+
teardown do
|
|
123
|
+
clear_emulation if self.class.harness_hook?(:clear_emulation) && devtools?
|
|
124
|
+
ensure
|
|
125
|
+
Thread.current[SCRIPT_TIMEOUT_KEY] = nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def reset_window
|
|
129
|
+
page.driver.browser.manage.window.resize_to(*self.class.desktop_size)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A real phone-width viewport. Headless Chrome will not size its window
|
|
133
|
+
# under 500px, so resize_to(390, ...) gives a 500px page and a "390px" check
|
|
134
|
+
# passes at the wrong width. DevTools device-metrics emulation sets the
|
|
135
|
+
# viewport itself. After each visit, before you measure, call
|
|
136
|
+
# assert_viewport_width. The clear_emulation hook clears it in teardown.
|
|
137
|
+
def phone_viewport!(width = self.class.phone_width, height = self.class.phone_height)
|
|
138
|
+
page.driver.browser.execute_cdp("Emulation.setDeviceMetricsOverride",
|
|
139
|
+
width: width, height: height, deviceScaleFactor: 1, mobile: true)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Reads clientWidth, not innerWidth: under mobile emulation innerWidth grows
|
|
143
|
+
# to fit a page that overflows, so a check against it passes vacuously.
|
|
144
|
+
# Phone widths only: a desktop Linux Chrome takes 15px of clientWidth for
|
|
145
|
+
# its scrollbar.
|
|
146
|
+
def assert_viewport_width(width = self.class.phone_width)
|
|
147
|
+
assert_equal width, evaluate_script("document.documentElement.clientWidth"), "the viewport is #{width}px"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Clears both DevTools overrides a test may set: the phone viewport and the
|
|
151
|
+
# emulated media (prefers-color-scheme, prefers-reduced-motion, print).
|
|
152
|
+
def clear_emulation
|
|
153
|
+
page.driver.browser.execute_cdp("Emulation.clearDeviceMetricsOverride")
|
|
154
|
+
page.driver.browser.execute_cdp("Emulation.setEmulatedMedia", media: "", features: [])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# A real browser window: Selenium, not rack_test. A subclass driven by
|
|
160
|
+
# rack_test skips the browser hooks without opting out.
|
|
161
|
+
def browser_window?
|
|
162
|
+
page.driver.respond_to?(:browser) && page.driver.browser.respond_to?(:manage)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A Chromium browser that takes DevTools commands.
|
|
166
|
+
def devtools?
|
|
167
|
+
browser_window? && page.driver.browser.respond_to?(:execute_cdp)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/studio/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.144.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -1022,6 +1022,7 @@ files:
|
|
|
1022
1022
|
- lib/studio/survey.rb
|
|
1023
1023
|
- lib/studio/survey/breakdown.rb
|
|
1024
1024
|
- lib/studio/survey/export.rb
|
|
1025
|
+
- lib/studio/system_test_case.rb
|
|
1025
1026
|
- lib/studio/tailwind_entry.rb
|
|
1026
1027
|
- lib/studio/theme_resolver.rb
|
|
1027
1028
|
- lib/studio/tricks.rb
|