studio-engine 0.96.2 → 0.97.0.rc1
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 +183 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
- data/app/assets/tailwind/studio_engine/engine.css +8 -0
- data/app/javascript/studio/alpine_shims.js +23 -0
- data/app/javascript/studio/application.js +9 -21
- data/app/javascript/studio/board.js +593 -0
- data/app/javascript/studio/booking.js +176 -0
- data/app/javascript/studio/controllers/board_controller.js +41 -0
- data/app/javascript/studio/controllers/booking_controller.js +24 -0
- data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
- data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
- data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
- data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
- data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
- data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
- data/app/javascript/studio/controllers/survey_controller.js +18 -0
- data/app/javascript/studio/controllers/toast_controller.js +26 -0
- data/app/javascript/studio/email_banner.js +277 -0
- data/app/javascript/studio/email_banner_scale.js +60 -0
- data/app/javascript/studio/footer_map.js +143 -0
- data/app/javascript/studio/geo_settings.js +105 -0
- data/app/javascript/studio/hold_button.js +416 -0
- data/app/javascript/studio/hold_button_hooks.js +162 -0
- data/app/javascript/studio/lazy_controllers.js +138 -0
- data/app/javascript/studio/leveling_activity.js +252 -0
- data/app/javascript/studio/link_preview_card.js +34 -0
- data/app/javascript/studio/link_sidebar.js +157 -0
- data/app/javascript/studio/stimulus.js +72 -0
- data/app/javascript/studio/survey.js +316 -0
- data/app/javascript/studio/theme_editor.js +197 -0
- data/app/javascript/studio/toast.js +270 -0
- data/app/views/components/_link_sidebar.html.erb +5 -100
- data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
- data/app/views/layouts/studio/_flash.html.erb +13 -118
- data/app/views/layouts/studio/_head.html.erb +30 -14
- data/app/views/studio/_board_assets.html.erb +9 -452
- data/app/views/studio/_hold_button.html.erb +38 -445
- data/app/views/studio/_hold_button_guard.html.erb +172 -0
- data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
- data/app/views/studio/board/_board.html.erb +16 -5
- data/app/views/studio/booking/_assets.html.erb +5 -139
- data/app/views/studio/booking/_frame.html.erb +3 -3
- data/app/views/studio/booking/_popup.html.erb +4 -4
- data/app/views/studio/emails/_banner_editor.html.erb +4 -152
- data/app/views/studio/emails/_banner_scale.html.erb +5 -56
- data/app/views/studio/emails/index.html.erb +0 -1
- data/app/views/studio/geo_settings/edit.html.erb +1 -97
- data/app/views/studio/site_footer/_map.html.erb +7 -5
- data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
- data/app/views/studio/site_identities/edit.html.erb +1 -24
- data/app/views/studio/surveys/show.html.erb +4 -5
- data/app/views/style/_tasks.html.erb +3 -8
- data/app/views/style/_theme.html.erb +2 -52
- data/app/views/theme_settings/edit.html.erb +3 -82
- data/config/importmap.rb +6 -0
- data/docs/BOOKING.md +6 -5
- data/docs/SITE_FOOTER.md +16 -12
- data/lib/studio/version.rb +1 -1
- metadata +28 -3
- data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
- data/app/views/studio/surveys/_script.html.erb +0 -309
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// studio/lazy_controllers: registers a page-specific controller the first time
|
|
2
|
+
// an element names it, so its module is fetched only by a page that uses it.
|
|
3
|
+
//
|
|
4
|
+
// watchLazyControllers({
|
|
5
|
+
// root: document.documentElement,
|
|
6
|
+
// attribute: "data-studio-controller",
|
|
7
|
+
// loaders: { board: () => import("studio/controllers/board_controller") },
|
|
8
|
+
// register: (name, controller) => application.register(name, controller)
|
|
9
|
+
// })
|
|
10
|
+
//
|
|
11
|
+
// An every-page controller is imported statically, which puts it in the
|
|
12
|
+
// preloaded boot graph. A lazy one is named here by a dynamic import, which the
|
|
13
|
+
// boot graph does not follow.
|
|
14
|
+
//
|
|
15
|
+
// UNTIL A LAZY CONTROLLER REGISTERS its element's actions do nothing: Stimulus
|
|
16
|
+
// ignores an action whose controller it does not know.
|
|
17
|
+
//
|
|
18
|
+
// A LOAD THAT FAILS IS FINAL FOR THE DOCUMENT. A browser keeps a failed module
|
|
19
|
+
// fetch in the document's module map and rejects every later import() of it
|
|
20
|
+
// without a new request, across DOM changes and Turbo visits; only a full page
|
|
21
|
+
// load fetches it again. So the registry imports a controller once. When the
|
|
22
|
+
// import fails it reports the failure once and marks every element that names
|
|
23
|
+
// the controller, those on the page and those that arrive later:
|
|
24
|
+
//
|
|
25
|
+
// <div data-studio-controller="board" data-studio-controller-failed="board">
|
|
26
|
+
//
|
|
27
|
+
// A control that must work whenever its page does is therefore not lazy: the
|
|
28
|
+
// hold button is imported statically by studio/stimulus.
|
|
29
|
+
//
|
|
30
|
+
// It imports nothing; test/javascript/lazy_controllers.test.mjs loads it as a
|
|
31
|
+
// data: module.
|
|
32
|
+
|
|
33
|
+
// The attribute a failed controller's elements carry: the identifiers, among
|
|
34
|
+
// those the element names, whose module failed to load.
|
|
35
|
+
export const FAILED_ATTRIBUTE = "data-studio-controller-failed"
|
|
36
|
+
|
|
37
|
+
// The identifiers in one data-studio-controller value.
|
|
38
|
+
export function controllerNames(value) {
|
|
39
|
+
return String(value || "").split(/\s+/).filter(Boolean)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `root` itself, when it carries `attribute`, and every element beneath it
|
|
43
|
+
// that does.
|
|
44
|
+
function elementsWith(root, attribute) {
|
|
45
|
+
const elements = []
|
|
46
|
+
if (!root) return elements
|
|
47
|
+
if (typeof root.getAttribute === "function" && root.getAttribute(attribute) != null) elements.push(root)
|
|
48
|
+
if (typeof root.querySelectorAll === "function") elements.push(...root.querySelectorAll(`[${attribute}]`))
|
|
49
|
+
return elements
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The identifiers from `names` that an element in `root`, or `root` itself,
|
|
53
|
+
// carries in `attribute`.
|
|
54
|
+
export function lazyNamesIn(root, names, attribute) {
|
|
55
|
+
const wanted = new Set(names)
|
|
56
|
+
const found = new Set()
|
|
57
|
+
if (wanted.size === 0) return []
|
|
58
|
+
|
|
59
|
+
for (const element of elementsWith(root, attribute)) {
|
|
60
|
+
for (const name of controllerNames(element.getAttribute(attribute))) {
|
|
61
|
+
if (wanted.has(name)) found.add(name)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...found]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Writes `failedAttribute` on every element in `root` that names a controller
|
|
68
|
+
// in `failed`. Returns the elements it marked on this pass.
|
|
69
|
+
export function markFailed(root, failed, attribute, failedAttribute = FAILED_ATTRIBUTE) {
|
|
70
|
+
const lost = new Set(failed)
|
|
71
|
+
const marked = []
|
|
72
|
+
if (lost.size === 0) return marked
|
|
73
|
+
|
|
74
|
+
for (const element of elementsWith(root, attribute)) {
|
|
75
|
+
const names = controllerNames(element.getAttribute(attribute)).filter((name) => lost.has(name))
|
|
76
|
+
if (names.length === 0 || typeof element.setAttribute !== "function") continue
|
|
77
|
+
|
|
78
|
+
const value = [...new Set(names)].join(" ")
|
|
79
|
+
if (element.getAttribute(failedAttribute) === value) continue
|
|
80
|
+
element.setAttribute(failedAttribute, value)
|
|
81
|
+
marked.push(element)
|
|
82
|
+
}
|
|
83
|
+
return marked
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Loads and registers each lazy controller once, on first sight: now, for the
|
|
87
|
+
// elements already in `root`, and after any change beneath it. A controller
|
|
88
|
+
// whose load fails is never loaded again; its elements are marked instead.
|
|
89
|
+
// Returns { pending, failed, scan, stop, started }. The observer stops by
|
|
90
|
+
// itself once every controller has registered; while one has failed it keeps
|
|
91
|
+
// watching, to mark the elements that arrive later.
|
|
92
|
+
export function watchLazyControllers({ root, attribute, loaders, register, Observer, report, failedAttribute }) {
|
|
93
|
+
const pending = new Set(Object.keys(loaders || {}))
|
|
94
|
+
const loading = new Set()
|
|
95
|
+
const failed = new Map()
|
|
96
|
+
const complain = report || ((name, error) => console.error(
|
|
97
|
+
`[studio] the ${name} controller failed to load and stays unavailable until the page is reloaded`, error
|
|
98
|
+
))
|
|
99
|
+
let observer = null
|
|
100
|
+
|
|
101
|
+
const stop = () => {
|
|
102
|
+
if (observer) observer.disconnect()
|
|
103
|
+
observer = null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const mark = () => markFailed(root, failed.keys(), attribute, failedAttribute || FAILED_ATTRIBUTE)
|
|
107
|
+
|
|
108
|
+
const load = (name) => {
|
|
109
|
+
pending.delete(name)
|
|
110
|
+
loading.add(name)
|
|
111
|
+
return Promise.resolve()
|
|
112
|
+
.then(() => loaders[name]())
|
|
113
|
+
.then((module) => {
|
|
114
|
+
register(name, module && module.default ? module.default : module)
|
|
115
|
+
loading.delete(name)
|
|
116
|
+
if (pending.size === 0 && loading.size === 0 && failed.size === 0) stop()
|
|
117
|
+
})
|
|
118
|
+
.catch((error) => {
|
|
119
|
+
loading.delete(name)
|
|
120
|
+
failed.set(name, error)
|
|
121
|
+
mark()
|
|
122
|
+
complain(name, error)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const scan = () => {
|
|
127
|
+
mark()
|
|
128
|
+
return Promise.all(lazyNamesIn(root, [...pending], attribute).map(load))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ObserverClass = Observer || (typeof MutationObserver !== "undefined" ? MutationObserver : null)
|
|
132
|
+
if (ObserverClass && pending.size > 0) {
|
|
133
|
+
observer = new ObserverClass(() => { if (pending.size > 0 || failed.size > 0) scan() })
|
|
134
|
+
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: [attribute] })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { pending, failed, scan, stop, started: scan() }
|
|
138
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// studio/leveling_activity: the Alpine scope behind the leveling-activity modals
|
|
2
|
+
// (studio/modals/blocks/_leveling_activity and its _change_username
|
|
3
|
+
// specialisation): one action, with or without a single-line input, that an app
|
|
4
|
+
// endpoint saves.
|
|
5
|
+
//
|
|
6
|
+
// <div x-data="levelingActionModal({ hasInput: true, submitUrl: '/account/username' })">
|
|
7
|
+
//
|
|
8
|
+
// The modal mounts inside a modal host's template, so Alpine evaluates the
|
|
9
|
+
// x-data when the modal opens. studio/alpine_shims imports this module and
|
|
10
|
+
// publishes the factory as window.levelingActionModal on every page.
|
|
11
|
+
//
|
|
12
|
+
// UI ONLY. It POSTs to an app-supplied submitUrl and reads a neutral JSON
|
|
13
|
+
// contract. A second step some apps need is delegated opaquely: the engine
|
|
14
|
+
// hands the app's "challenge" blob to the app's window[finalizeHook] and POSTs
|
|
15
|
+
// back whatever "proof" the hook returns. It inspects neither.
|
|
16
|
+
//
|
|
17
|
+
// The contract (submitUrl and finalizeUrl answer JSON):
|
|
18
|
+
// POST submitUrl { value } answers one of
|
|
19
|
+
// { status: "saved", seeds_earned?, seeds_total? } done
|
|
20
|
+
// { status: "needs_step", challenge, token } the app's step is required
|
|
21
|
+
// { status: "error", message } the message is shown
|
|
22
|
+
// on needs_step, with a finalizeHook:
|
|
23
|
+
// proof = await window[finalizeHook](challenge, { token, onProgress })
|
|
24
|
+
// POST finalizeUrl { token, proof } answers { status: "saved" | "error", ... }
|
|
25
|
+
//
|
|
26
|
+
// opts:
|
|
27
|
+
// hasInput true renders and validates a single-line input; false is a
|
|
28
|
+
// plain "Complete" action.
|
|
29
|
+
// initialValue the input's starting value, a string.
|
|
30
|
+
// minLength the input's minimum length, checked in the browser (0 = none).
|
|
31
|
+
// submitUrl the app endpoint the action POSTs to; the app owns the save.
|
|
32
|
+
// finalizeUrl the app endpoint for the second step, when there is one.
|
|
33
|
+
// finalizeHook the name of the window async function that runs that step.
|
|
34
|
+
// savedEvent the window event dispatched on success, carrying the app's
|
|
35
|
+
// payload. Default "studio:activity-saved". A caller that names
|
|
36
|
+
// its own event runs the follow-on itself (see appDrivenFollowOn).
|
|
37
|
+
// store the Alpine modal store behind close(). Default "modals".
|
|
38
|
+
// leveling the default for the seeds celebration. The live value is the
|
|
39
|
+
// open modal's props.leveling (see the leveling getter).
|
|
40
|
+
// hasConsent true renders a consent checkbox that gates the action.
|
|
41
|
+
// demo the style-guide preview: it resolves locally, POSTs nothing
|
|
42
|
+
// and dispatches no saved event.
|
|
43
|
+
// seedsPerLevel seeds per level for the celebration bar. Default 100.
|
|
44
|
+
// demoSeedsEarned, demoSeedsTotal the demo's seed payload. Default 30 and
|
|
45
|
+
// seedsPerLevel, which crosses a level.
|
|
46
|
+
//
|
|
47
|
+
// It imports nothing; test/javascript/leveling_activity.test.mjs loads it as a
|
|
48
|
+
// data: module.
|
|
49
|
+
|
|
50
|
+
export const DEFAULT_SAVED_EVENT = "studio:activity-saved"
|
|
51
|
+
|
|
52
|
+
const GENERIC_ERROR = "Something went wrong. Please try again."
|
|
53
|
+
const STEP_ERROR = "Couldn't complete the step."
|
|
54
|
+
|
|
55
|
+
// Whether the action can run. An input activity needs a real change of at
|
|
56
|
+
// least minLength; a plain action runs once. An unticked consent box gates both.
|
|
57
|
+
export function actionable({ hasConsent, consent, hasInput, saved, value, original, minLength }) {
|
|
58
|
+
if (hasConsent && !consent) return false
|
|
59
|
+
if (!hasInput) return !saved
|
|
60
|
+
|
|
61
|
+
const trimmed = (value || "").trim()
|
|
62
|
+
if (trimmed.length < minLength) return false
|
|
63
|
+
return trimmed !== (original || "").trim()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A response's status: the body's own, or "saved" for a 2xx that names none.
|
|
67
|
+
export function responseStatus(result) {
|
|
68
|
+
const body = (result && result.body) || {}
|
|
69
|
+
return body.status || (result && result.ok ? "saved" : "error")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The demo's seed payload: the pair the caller chose, or one that crosses a level.
|
|
73
|
+
export function demoSeeds(earned, total, seedsPerLevel) {
|
|
74
|
+
return {
|
|
75
|
+
seeds_earned: Number.isNaN(earned) ? 30 : earned,
|
|
76
|
+
seeds_total: Number.isNaN(total) ? seedsPerLevel : total
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function levelingActionModal(opts, { win = window, doc = document } = {}) {
|
|
81
|
+
opts = opts || {}
|
|
82
|
+
return {
|
|
83
|
+
hasInput: !!opts.hasInput,
|
|
84
|
+
value: opts.initialValue || "",
|
|
85
|
+
original: opts.initialValue || "",
|
|
86
|
+
minLength: parseInt(opts.minLength, 10) || 0,
|
|
87
|
+
submitUrl: opts.submitUrl || "",
|
|
88
|
+
finalizeUrl: opts.finalizeUrl || "",
|
|
89
|
+
finalizeHook: opts.finalizeHook || "",
|
|
90
|
+
savedEvent: opts.savedEvent || DEFAULT_SAVED_EVENT,
|
|
91
|
+
store: opts.store || "modals",
|
|
92
|
+
_levelingDefault: !!opts.leveling,
|
|
93
|
+
hasConsent: !!opts.hasConsent,
|
|
94
|
+
consent: false,
|
|
95
|
+
demo: !!opts.demo,
|
|
96
|
+
seedsPerLevel: parseInt(opts.seedsPerLevel, 10) || 100,
|
|
97
|
+
demoSeedsEarned: parseInt(opts.demoSeedsEarned, 10),
|
|
98
|
+
demoSeedsTotal: parseInt(opts.demoSeedsTotal, 10),
|
|
99
|
+
saving: false,
|
|
100
|
+
error: "",
|
|
101
|
+
saved: false,
|
|
102
|
+
celebrate: false,
|
|
103
|
+
progressLabel: "",
|
|
104
|
+
seedsEarned: 0,
|
|
105
|
+
seedsTotal: 0,
|
|
106
|
+
|
|
107
|
+
// The props of the modal that is open on this scope's store, or null.
|
|
108
|
+
_props() {
|
|
109
|
+
const store = (win.Alpine && win.Alpine.store) ? win.Alpine.store(this.store) : null
|
|
110
|
+
const current = (store && typeof store.current === "function") ? store.current() : null
|
|
111
|
+
return (current && current.props) || null
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
// props.celebrate opens the modal already saved, at the updated state, with
|
|
115
|
+
// the seeds the celebration animates from.
|
|
116
|
+
init() {
|
|
117
|
+
const props = this._props() || {}
|
|
118
|
+
if (!props.celebrate) return
|
|
119
|
+
|
|
120
|
+
this.seedsEarned = parseInt(props.seedsEarned, 10) || 0
|
|
121
|
+
this.seedsTotal = parseInt(props.seedsTotal, 10) || 0
|
|
122
|
+
this.saved = true
|
|
123
|
+
this.celebrate = true
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// Read from the open modal's props on every access, so one modal id shows
|
|
127
|
+
// the seeds celebration or the plain confirmation as a toggle moves. A modal
|
|
128
|
+
// whose props do not say keeps the default it was rendered with.
|
|
129
|
+
get leveling() {
|
|
130
|
+
const props = this._props()
|
|
131
|
+
if (props && typeof props.leveling === "boolean") return props.leveling
|
|
132
|
+
return this._levelingDefault
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
get changed() {
|
|
136
|
+
return actionable(this)
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// A caller that is not a demo and names its own saved event owns what
|
|
140
|
+
// follows a save: its listener closes, swaps or advances the modal. The
|
|
141
|
+
// engine then dispatches the event and stops, so the app's next step
|
|
142
|
+
// renders once. A demo, and a caller on the default event, get the engine's
|
|
143
|
+
// own confirmation or celebration.
|
|
144
|
+
get appDrivenFollowOn() {
|
|
145
|
+
if (this.demo) return false
|
|
146
|
+
return this.savedEvent !== DEFAULT_SAVED_EVENT
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
// A demo dispatches nothing: a host listening for the saved event would
|
|
150
|
+
// open its own follow-on modal over the preview.
|
|
151
|
+
_dispatch(payload) {
|
|
152
|
+
if (this.demo) return
|
|
153
|
+
try {
|
|
154
|
+
win.dispatchEvent(new win.CustomEvent(this.savedEvent, { detail: payload || {} }))
|
|
155
|
+
} catch (error) {}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
_finishSaved(payload) {
|
|
159
|
+
payload = payload || {}
|
|
160
|
+
this.saving = false
|
|
161
|
+
this.saved = true
|
|
162
|
+
this.progressLabel = ""
|
|
163
|
+
if (this.hasInput) this.original = (this.value || "").trim()
|
|
164
|
+
this._dispatch(payload)
|
|
165
|
+
// The app's listener owns close against swap, so the engine closes nothing.
|
|
166
|
+
if (this.appDrivenFollowOn) return
|
|
167
|
+
|
|
168
|
+
// The updated state. Which view shows it (the seeds celebration or the
|
|
169
|
+
// plain confirmation) is the leveling getter's call.
|
|
170
|
+
this.seedsEarned = parseInt(payload.seeds_earned, 10) || 0
|
|
171
|
+
this.seedsTotal = parseInt(payload.seeds_total, 10) || 0
|
|
172
|
+
this.celebrate = true
|
|
173
|
+
this._syncCelebrate(true)
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// Mirrors celebrate onto the open modal's props, which the style guide's
|
|
177
|
+
// glow reads, so the glow follows the modal from its input card to its
|
|
178
|
+
// updated card.
|
|
179
|
+
_syncCelebrate(value) {
|
|
180
|
+
const props = this._props()
|
|
181
|
+
if (props) props.celebrate = !!value
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
_post(url, body) {
|
|
185
|
+
const csrf = (doc.querySelector('meta[name="csrf-token"]') || {}).content || ""
|
|
186
|
+
const fetcher = win.authedFetch || win.fetch
|
|
187
|
+
return fetcher(url, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
|
|
190
|
+
body: JSON.stringify(body || {})
|
|
191
|
+
}).then((response) => response.json().then((json) => ({ ok: response.ok, body: json || {} })))
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
save() {
|
|
195
|
+
if (this.saving || !this.changed) return
|
|
196
|
+
this.saving = true
|
|
197
|
+
this.error = ""
|
|
198
|
+
|
|
199
|
+
if (this.demo) {
|
|
200
|
+
const seeds = demoSeeds(this.demoSeedsEarned, this.demoSeedsTotal, this.seedsPerLevel)
|
|
201
|
+
setTimeout(() => { this._finishSaved(seeds) }, 600)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const body = this.hasInput ? { value: (this.value || "").trim() } : {}
|
|
206
|
+
this._post(this.submitUrl, body)
|
|
207
|
+
.then((result) => this._handle(result))
|
|
208
|
+
.catch((error) => {
|
|
209
|
+
this.error = (error && error.message) || GENERIC_ERROR
|
|
210
|
+
this.saving = false
|
|
211
|
+
})
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
_handle(result) {
|
|
215
|
+
const body = (result && result.body) || {}
|
|
216
|
+
const status = responseStatus(result)
|
|
217
|
+
if (status === "saved") { this._finishSaved(body); return }
|
|
218
|
+
if (status === "needs_step") return this._step(body)
|
|
219
|
+
this.error = body.message || GENERIC_ERROR
|
|
220
|
+
this.saving = false
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
// The second step. The app's hook turns the challenge into a proof, and the
|
|
224
|
+
// proof goes back to finalizeUrl with the token.
|
|
225
|
+
_step(body) {
|
|
226
|
+
const hook = this.finalizeHook && win[this.finalizeHook]
|
|
227
|
+
if (typeof hook !== "function") {
|
|
228
|
+
this.error = "This step isn't available."
|
|
229
|
+
this.saving = false
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return Promise.resolve(
|
|
234
|
+
hook(body.challenge, {
|
|
235
|
+
token: body.token,
|
|
236
|
+
onProgress: (label) => { this.progressLabel = label || "" }
|
|
237
|
+
})
|
|
238
|
+
).then((proof) => this._post(this.finalizeUrl, { token: body.token, proof })).then((result) => {
|
|
239
|
+
const answer = (result && result.body) || {}
|
|
240
|
+
if (responseStatus(result) === "saved") {
|
|
241
|
+
this._finishSaved(answer)
|
|
242
|
+
} else {
|
|
243
|
+
this.error = answer.message || STEP_ERROR
|
|
244
|
+
this.saving = false
|
|
245
|
+
}
|
|
246
|
+
}).catch((error) => {
|
|
247
|
+
this.error = (error && error.message) || STEP_ERROR
|
|
248
|
+
this.saving = false
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// studio/link_preview_card: the live card on the site identity manager
|
|
2
|
+
// (/admin/link_preview). Typing in the title or the description repaints the
|
|
3
|
+
// card before anything is saved, falling back to what a blank field really
|
|
4
|
+
// sends.
|
|
5
|
+
//
|
|
6
|
+
// It imports nothing; test/javascript/link_preview_card.test.mjs loads it as a
|
|
7
|
+
// data: module. Bound to the page by the link-preview-card controller.
|
|
8
|
+
|
|
9
|
+
// What the card shows for a field: its trimmed value, or the fallback when the
|
|
10
|
+
// field is blank.
|
|
11
|
+
export function cardText(value, fallback) {
|
|
12
|
+
const trimmed = String(value == null ? "" : value).trim()
|
|
13
|
+
return trimmed || fallback || ""
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The page's fallbacks, from data-fallback-title and data-fallback-description.
|
|
17
|
+
export function fallbacksOf(page) {
|
|
18
|
+
return {
|
|
19
|
+
title: page.dataset.fallbackTitle || "",
|
|
20
|
+
description: page.dataset.fallbackDescription || ""
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Repaints the card node that `input` (a [data-link-preview-input]) feeds.
|
|
25
|
+
// Answers the text it wrote, or null when the input feeds no card node.
|
|
26
|
+
export function paintCard(page, input) {
|
|
27
|
+
const key = input && input.dataset ? input.dataset.linkPreviewInput : null
|
|
28
|
+
if (!key) return null
|
|
29
|
+
const target = page.querySelector("[data-link-preview-card-" + key + "]")
|
|
30
|
+
if (!target) return null
|
|
31
|
+
const text = cardText(input.value, fallbacksOf(page)[key])
|
|
32
|
+
target.textContent = text
|
|
33
|
+
return text
|
|
34
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// studio/link_sidebar: the open flag behind components/_link_sidebar
|
|
2
|
+
// ($store.sidebars.linkTreeOpen) and the document handlers that flip it.
|
|
3
|
+
//
|
|
4
|
+
// The panel's open and closed state is the store flag alone, through x-show and
|
|
5
|
+
// x-transition. Nothing here sets `display`: an inline display:none makes
|
|
6
|
+
// Alpine skip the enter transition, and the panel pops instead of sliding.
|
|
7
|
+
//
|
|
8
|
+
// The partial renders markup only, plus one anchor for its controller:
|
|
9
|
+
//
|
|
10
|
+
// <template data-studio-controller="link-sidebar"></template>
|
|
11
|
+
//
|
|
12
|
+
// WHEN THE STORE REGISTERS. The panel's x-show reads $store.sidebars as Alpine
|
|
13
|
+
// starts, before Stimulus connects a controller, so the flag is registered from
|
|
14
|
+
// this module, on a page that renders the panel and only there:
|
|
15
|
+
//
|
|
16
|
+
// 1. alpine:init, on a full load.
|
|
17
|
+
// 2. turbo:before-render, for the incoming body, before Alpine sees it.
|
|
18
|
+
// 3. turbo:load, and the controller's connect(), for markup that arrived any
|
|
19
|
+
// other way.
|
|
20
|
+
//
|
|
21
|
+
// A host with its own `sidebars` store keeps it and gains the flag
|
|
22
|
+
// (turf-monster's holds gearOpen).
|
|
23
|
+
//
|
|
24
|
+
// THE DOCUMENT HANDLERS run in the capture phase, so the trigger works for a
|
|
25
|
+
// click that lands before Alpine has bound it. They are installed once, on
|
|
26
|
+
// every page, and do nothing where no link sidebar is rendered.
|
|
27
|
+
//
|
|
28
|
+
// WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports this module by
|
|
29
|
+
// its own nonced module tag as well as through the boot graph, like
|
|
30
|
+
// studio/alpine_stores. It imports nothing, and
|
|
31
|
+
// test/javascript/link_sidebar.test.mjs holds it to that.
|
|
32
|
+
|
|
33
|
+
// The panels components/_link_sidebar renders: the scope of every click this
|
|
34
|
+
// module claims.
|
|
35
|
+
export const LINK_SIDEBAR_PANELS = "#studio-link-sidebar, #studio-link-sidebar-mobile"
|
|
36
|
+
// A control that may toggle the sidebar, when its aria-controls names the panel.
|
|
37
|
+
export const LINK_SIDEBAR_TRIGGERS = "[data-link-sidebar-trigger], [data-username-display], [data-profile-image-toggle]"
|
|
38
|
+
const CONTROLS_PANEL = '[aria-controls~="studio-link-sidebar"]'
|
|
39
|
+
const CLOSE_BUTTON = "[data-link-sidebar-close]"
|
|
40
|
+
|
|
41
|
+
// Registers the flag: a new `sidebars` store, or one more key on the host's.
|
|
42
|
+
// Null before Alpine exists.
|
|
43
|
+
export function ensureLinkSidebarStore(Alpine) {
|
|
44
|
+
if (!Alpine || typeof Alpine.store !== "function") return null
|
|
45
|
+
|
|
46
|
+
const sidebars = Alpine.store("sidebars")
|
|
47
|
+
if (!sidebars) {
|
|
48
|
+
Alpine.store("sidebars", { linkTreeOpen: false })
|
|
49
|
+
return Alpine.store("sidebars")
|
|
50
|
+
}
|
|
51
|
+
if (typeof sidebars.linkTreeOpen === "undefined") sidebars.linkTreeOpen = false
|
|
52
|
+
return sidebars
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The store as it stands. Reading it registers nothing, so a page with no link
|
|
56
|
+
// sidebar gains no store from a click or a key press.
|
|
57
|
+
export function linkSidebarStore(Alpine) {
|
|
58
|
+
if (!Alpine || typeof Alpine.store !== "function") return null
|
|
59
|
+
return Alpine.store("sidebars") || null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function closeLinkSidebar(Alpine) {
|
|
63
|
+
const sidebars = linkSidebarStore(Alpine)
|
|
64
|
+
if (sidebars && sidebars.linkTreeOpen) sidebars.linkTreeOpen = false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function rendersLinkSidebar(root) {
|
|
68
|
+
return !!(root && typeof root.querySelector === "function" && root.querySelector(LINK_SIDEBAR_PANELS))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Path 1, 2 and 3 above: the flag for a document or body that renders the panel.
|
|
72
|
+
export function registerLinkSidebar(Alpine, root) {
|
|
73
|
+
return rendersLinkSidebar(root) ? ensureLinkSidebarStore(Alpine) : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What a click on `target` means to the link sidebar:
|
|
77
|
+
//
|
|
78
|
+
// "close" the close button of one of ITS panels. components/_sidebar_panel
|
|
79
|
+
// is shared and stamps every panel's close button
|
|
80
|
+
// data-link-sidebar-close, so the button counts only inside a panel
|
|
81
|
+
// this component renders; another panel's is left to that panel.
|
|
82
|
+
// "toggle" a trigger whose aria-controls names the panel.
|
|
83
|
+
// "inside" anywhere else in one of its panels.
|
|
84
|
+
// "outside" the rest of the page.
|
|
85
|
+
export function clickIntent(target) {
|
|
86
|
+
if (!target || typeof target.closest !== "function") return "outside"
|
|
87
|
+
|
|
88
|
+
const closeButton = target.closest(CLOSE_BUTTON)
|
|
89
|
+
if (closeButton && closeButton.closest(LINK_SIDEBAR_PANELS)) return "close"
|
|
90
|
+
|
|
91
|
+
const trigger = target.closest(LINK_SIDEBAR_TRIGGERS)
|
|
92
|
+
if (trigger && trigger.matches(CONTROLS_PANEL)) return "toggle"
|
|
93
|
+
|
|
94
|
+
return target.closest(LINK_SIDEBAR_PANELS) ? "inside" : "outside"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const claim = (event) => {
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
event.stopPropagation()
|
|
100
|
+
event.stopImmediatePropagation()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The capture-phase click. A claimed click reaches no other listener, so the
|
|
104
|
+
// trigger's own Alpine @click cannot toggle the flag a second time.
|
|
105
|
+
export function handleLinkSidebarClick(event, Alpine) {
|
|
106
|
+
const intent = clickIntent(event.target)
|
|
107
|
+
|
|
108
|
+
if (intent === "close") {
|
|
109
|
+
claim(event)
|
|
110
|
+
closeLinkSidebar(Alpine)
|
|
111
|
+
return intent
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (intent === "toggle") {
|
|
115
|
+
const sidebars = ensureLinkSidebarStore(Alpine)
|
|
116
|
+
if (!sidebars) return null
|
|
117
|
+
claim(event)
|
|
118
|
+
sidebars.linkTreeOpen = !sidebars.linkTreeOpen
|
|
119
|
+
return intent
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (intent === "outside") closeLinkSidebar(Alpine)
|
|
123
|
+
return intent
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function handleLinkSidebarKeydown(event, Alpine) {
|
|
127
|
+
if (event.key === "Escape") closeLinkSidebar(Alpine)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const installed = new WeakSet()
|
|
131
|
+
|
|
132
|
+
// Once per window: a second call adds no second listener.
|
|
133
|
+
export function installLinkSidebar({ win, doc } = {}) {
|
|
134
|
+
const host = win || globalThis
|
|
135
|
+
const root = doc || host.document
|
|
136
|
+
if (!root || typeof root.addEventListener !== "function") return false
|
|
137
|
+
if (installed.has(host)) return false
|
|
138
|
+
installed.add(host)
|
|
139
|
+
|
|
140
|
+
const register = () => registerLinkSidebar(host.Alpine, root)
|
|
141
|
+
root.addEventListener("alpine:init", register)
|
|
142
|
+
root.addEventListener("turbo:load", register)
|
|
143
|
+
root.addEventListener("turbo:before-render", (event) => {
|
|
144
|
+
registerLinkSidebar(host.Alpine, event.detail && event.detail.newBody)
|
|
145
|
+
})
|
|
146
|
+
// A page restored from Turbo's cache or the bfcache comes back closed.
|
|
147
|
+
root.addEventListener("turbo:before-cache", () => closeLinkSidebar(host.Alpine))
|
|
148
|
+
host.addEventListener("pageshow", (event) => { if (event.persisted) closeLinkSidebar(host.Alpine) })
|
|
149
|
+
root.addEventListener("click", (event) => handleLinkSidebarClick(event, host.Alpine), true)
|
|
150
|
+
root.addEventListener("keydown", (event) => handleLinkSidebarKeydown(event, host.Alpine), true)
|
|
151
|
+
|
|
152
|
+
// Alpine already started (a host that loads it ahead of the module tags).
|
|
153
|
+
if (host.Alpine && host.Alpine.version) register()
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (typeof window !== "undefined") installLinkSidebar({ win: window, doc: document })
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// studio/stimulus: the engine's Stimulus application and its lazy controllers.
|
|
2
|
+
//
|
|
3
|
+
// The engine runs its own Stimulus application on its OWN attributes:
|
|
4
|
+
//
|
|
5
|
+
// data-studio-controller="nav-collapse" data-studio-action data-studio-target
|
|
6
|
+
//
|
|
7
|
+
// so a host's Stimulus application never sees an engine controller. That is not
|
|
8
|
+
// tidiness: stimulus-loading's lazy loader (cyvasse) imports
|
|
9
|
+
// "controllers/<identifier>_controller" for every data-controller it meets, and
|
|
10
|
+
// would log "Failed to autoload controller" for each engine element. Values and
|
|
11
|
+
// classes keep Stimulus' own naming (data-nav-collapse-scrolled-class).
|
|
12
|
+
// "@hotwired/stimulus" resolves to the engine's vendored copy unless the host
|
|
13
|
+
// pins its own.
|
|
14
|
+
//
|
|
15
|
+
// THREE WAYS A CONTROLLER REGISTERS. The every-page chrome (nav collapse, modal
|
|
16
|
+
// host, toast, link sidebar) is registered by studio/application, which imports
|
|
17
|
+
// it statically. The hold button is registered HERE, statically, because it
|
|
18
|
+
// confirms real actions (see below). A PAGE-SPECIFIC CONTROLLER is listed in
|
|
19
|
+
// LAZY below and registered by studio/lazy_controllers the first time an
|
|
20
|
+
// element names one.
|
|
21
|
+
//
|
|
22
|
+
// WHY THE HOLD BUTTON IS STATIC. A lazy controller is one more request, made
|
|
23
|
+
// when its element first appears: for the token modal, minutes after the page
|
|
24
|
+
// loaded. If that request fails once (a dropped connection, or a deploy that
|
|
25
|
+
// retired the digested file) the browser never fetches the module again in
|
|
26
|
+
// that document, and the button would render and take a press that does
|
|
27
|
+
// nothing. Imported here it arrives with the page, in the preloaded boot graph,
|
|
28
|
+
// and no later request stands between a press and its hold.
|
|
29
|
+
//
|
|
30
|
+
// WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports this module by
|
|
31
|
+
// its own nonced module tag as well as through the boot graph. Its only imports
|
|
32
|
+
// are Stimulus, studio/lazy_controllers and the hold button's controller (which
|
|
33
|
+
// imports studio/hold_button and studio/hold_button_hooks), so a throw or a
|
|
34
|
+
// missing file elsewhere in studio/application's graph still leaves a page its
|
|
35
|
+
// hold button.
|
|
36
|
+
import { Application, defaultSchema } from "@hotwired/stimulus"
|
|
37
|
+
import { watchLazyControllers } from "studio/lazy_controllers"
|
|
38
|
+
import HoldButtonController from "studio/controllers/hold_button_controller"
|
|
39
|
+
|
|
40
|
+
export const schema = {
|
|
41
|
+
...defaultSchema,
|
|
42
|
+
controllerAttribute: "data-studio-controller",
|
|
43
|
+
actionAttribute: "data-studio-action",
|
|
44
|
+
targetAttribute: "data-studio-target"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// identifier -> its controller module. Dynamic imports, so none of these joins
|
|
48
|
+
// the every-page boot graph (Studio::Engine.javascript_boot_graph). A control
|
|
49
|
+
// that must work whenever its page does is not listed here: a failed load is
|
|
50
|
+
// final for the document (studio/lazy_controllers). The board is listed: a
|
|
51
|
+
// board whose controller failed still renders, counts and toasts, and says that
|
|
52
|
+
// its cards do not drag (data-studio-controller-failed="board").
|
|
53
|
+
export const LAZY = {
|
|
54
|
+
"board": () => import("studio/controllers/board_controller"),
|
|
55
|
+
"geo-settings": () => import("studio/controllers/geo_settings_controller"),
|
|
56
|
+
"link-preview-card": () => import("studio/controllers/link_preview_card_controller"),
|
|
57
|
+
"email-banner-scale": () => import("studio/controllers/email_banner_scale_controller"),
|
|
58
|
+
"booking": () => import("studio/controllers/booking_controller"),
|
|
59
|
+
"footer-map": () => import("studio/controllers/footer_map_controller"),
|
|
60
|
+
"survey": () => import("studio/controllers/survey_controller")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const application = Application.start(document.documentElement, schema)
|
|
64
|
+
|
|
65
|
+
application.register("hold-button", HoldButtonController)
|
|
66
|
+
|
|
67
|
+
export const lazy = watchLazyControllers({
|
|
68
|
+
root: document.documentElement,
|
|
69
|
+
attribute: schema.controllerAttribute,
|
|
70
|
+
loaders: LAZY,
|
|
71
|
+
register: (name, controller) => application.register(name, controller)
|
|
72
|
+
})
|