Two review findings: - The load handler re-posted pendingPayload on every load event of the frame, so an in-app navigation or a self-reload posted a filter that belonged to the mount which had already delivered it, and logged it as that mount's. The handler now takes the payload (clearing pendingPayload and pendingTiming) before posting, so a later load has nothing to send. - The README illustrated report.json with one parameter while the committed file carries three, and the prose below reads as though one parameter were the shape. The example block is gone: the file is the example, and the keys are documented as a table with the parameter rules kept underneath.
427 lines
15 KiB
JavaScript
427 lines
15 KiB
JavaScript
/*
|
|
* A parent report page that emulates a SAS Visual Analytics report embedding
|
|
* Data Controller as data-driven content.
|
|
*
|
|
* Why this exists: on a real deployment the VA report and the DC app share a
|
|
* domain, and the report passes its prompts to the embedded object as a DDC
|
|
* message. There is no way to open that report without VA, so this page stands
|
|
* in for it: the dropdowns are the report's prompts, and the iframe is framed
|
|
* with VA's sandbox attribute set and fed the same message shape.
|
|
*
|
|
* What it deliberately does NOT reproduce: VA's own UI, the report's layout, and
|
|
* the way VA computes a prompt's value. It reproduces the contract that DC sees.
|
|
*
|
|
* Nothing here is part of the DC build. The host is not the child: shipping it
|
|
* in DC's deployment would put a test harness on customer servers and let DC be
|
|
* opened top-level as a page that frames itself.
|
|
*
|
|
* Kept to ES5-style script (no modules, no build step) because it is served as a
|
|
* plain file by SASjs Server, and because the server's CSP allows scripts from
|
|
* 'self' only - no inline script, no inline handlers.
|
|
*/
|
|
;(function () {
|
|
'use strict'
|
|
|
|
var FRAME = document.getElementById('frame')
|
|
var LOG = document.getElementById('log')
|
|
var PROMPTS = document.getElementById('prompts')
|
|
var heading = document.getElementById('heading')
|
|
var modeSelect = document.getElementById('mode')
|
|
var timingSelect = document.getElementById('timing')
|
|
|
|
// VA's own attribute set, overridden by report.json when it says otherwise.
|
|
var defaultSandbox = FRAME.getAttribute('sandbox')
|
|
|
|
var report = null
|
|
var frameLoaded = false
|
|
var earlyTimer = null
|
|
var attempts = 0
|
|
var pendingPayload = null
|
|
var pendingTiming = 'both'
|
|
// the origin the frame is allowed to receive a message on, and the document it
|
|
// had when the current replay started (see startEarlyReplay)
|
|
var targetOrigin = null
|
|
var mountDocument = null
|
|
|
|
function log(message) {
|
|
var time = new Date().toISOString().slice(11, 23)
|
|
LOG.textContent += '[' + time + '] ' + message + '\n'
|
|
LOG.scrollTop = LOG.scrollHeight
|
|
}
|
|
|
|
function promptNames() {
|
|
return report && report.parameters ? report.parameters : []
|
|
}
|
|
|
|
/**
|
|
* The select a prompt renders, found by walking the selects.
|
|
*
|
|
* Deliberately not `querySelector('[data-name="..."]')`: a name from the
|
|
* config that carries a quote or a bracket would make that selector throw and
|
|
* abort the whole apply, leaving the page with no frame at all.
|
|
*/
|
|
function promptField(name) {
|
|
var selects = PROMPTS.querySelectorAll('select')
|
|
|
|
for (var i = 0; i < selects.length; i++) {
|
|
if (selects[i].getAttribute('data-name') === name) return selects[i]
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/** The value the report would send for each prompt, right now. */
|
|
function message() {
|
|
return {
|
|
version: '1',
|
|
resultName: 'dd1',
|
|
rowCount: 0,
|
|
availableRowCount: 0,
|
|
data: [],
|
|
columns: [],
|
|
parameters: promptNames().map(function (parameter) {
|
|
var field = promptField(parameter.name)
|
|
var dataType = parameter.dataType || 'string'
|
|
var raw = field ? field.value : parameter.default
|
|
|
|
return {
|
|
name: parameter.name,
|
|
// The label is the key DC maps on (`va_parmap`), so it is config, not
|
|
// something derived from the name.
|
|
label: parameter.label,
|
|
dataType: dataType,
|
|
// A numeric prompt carries a number in VA's message, and DC's clause
|
|
// builder branches on the type: sending "42" instead of 42 quotes a
|
|
// value that the backend compares as a number.
|
|
value: coerce(raw, dataType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/** A prompt value in the shape its dataType promises. */
|
|
function coerce(value, dataType) {
|
|
if (dataType !== 'number') return value
|
|
|
|
var asNumber = Number(value)
|
|
|
|
// a prompt whose configured value is not numeric stays as it is rather than
|
|
// becoming NaN in the message
|
|
return value !== null && value !== '' && !isNaN(asNumber) ? asNumber : value
|
|
}
|
|
|
|
/**
|
|
* The embedded object's URL.
|
|
*
|
|
* `embed=va` arms DC's earliest listener (it is inert without it), and each
|
|
* parameter with a `column` becomes a `va_parmap` entry - `<label>-<column>`,
|
|
* split at the last hyphen, matched case-insensitively against the parameter
|
|
* label.
|
|
*/
|
|
function childUrl() {
|
|
var params = promptNames().filter(function (parameter) {
|
|
return parameter.column
|
|
})
|
|
|
|
var query = ['embed=va'].concat(
|
|
params.map(function (parameter) {
|
|
return (
|
|
'va_parmap=' +
|
|
encodeURIComponent(parameter.label + '-' + parameter.column)
|
|
)
|
|
})
|
|
)
|
|
|
|
return (
|
|
report.server.replace(/\/$/, '') +
|
|
'/AppStream/' +
|
|
report.app +
|
|
'/#/editor/' +
|
|
encodeURIComponent(report.table) +
|
|
'?' +
|
|
query.join('&')
|
|
)
|
|
}
|
|
|
|
function postToFrame(payload) {
|
|
var target = FRAME.contentWindow
|
|
if (!target) return
|
|
|
|
// The frame is the app on report.server, so that origin is the only one that
|
|
// should receive the filter clause. '*' would deliver into whatever document
|
|
// occupies the frame, including one that navigated elsewhere, and a
|
|
// misconfigured report.server would widen delivery instead of failing.
|
|
if (!targetOrigin) {
|
|
log(
|
|
'cannot post: report.server is not a URL, so the frame origin is unknown - ' +
|
|
JSON.stringify(report.server)
|
|
)
|
|
return
|
|
}
|
|
|
|
target.postMessage(payload, targetOrigin)
|
|
}
|
|
|
|
/** The frame's origin, from the config; null when report.server is not a URL. */
|
|
function frameOrigin() {
|
|
try {
|
|
return new URL(report.server).origin
|
|
} catch (error) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** True once DC has published what its earliest listener captured. */
|
|
function frameBuffered() {
|
|
try {
|
|
return !!(FRAME.contentWindow && FRAME.contentWindow.__vaLastMessage)
|
|
} catch (error) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the frame exposes the property the replay uses to stop early.
|
|
*
|
|
* It is a private global on the app's side, so it can go away without notice.
|
|
* Saying so when it is absent keeps "no capture seen" from reading as a
|
|
* harness failure when the probe itself is what changed.
|
|
*/
|
|
function captureProbeVisible() {
|
|
try {
|
|
return !!(FRAME.contentWindow && '__vaLastMessage' in FRAME.contentWindow)
|
|
} catch (error) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function stopEarlyReplay() {
|
|
if (earlyTimer === null) return
|
|
clearInterval(earlyTimer)
|
|
earlyTimer = null
|
|
}
|
|
|
|
/**
|
|
* Deliver the message the way VA does on a report refresh: repeatedly, from
|
|
* the moment the frame is created, so one copy lands after the app's earliest
|
|
* listener exists and before the app bootstraps. That window is the whole
|
|
* point - a single post on load reaches the metadata path instead.
|
|
*/
|
|
function startEarlyReplay(payload) {
|
|
var interval = report.intervalMs || 50
|
|
var deadline = Date.now() + (report.replayTimeoutMs || 30000)
|
|
|
|
attempts = 0
|
|
// The document on screen when this mount started. A remount to the same URL
|
|
// reloads the frame, and until the reload lands the OLD document is still
|
|
// there with the __vaLastMessage it captured on the previous mount - reading
|
|
// that would log a capture this mount never made. A capture only counts once
|
|
// the document has been replaced.
|
|
mountDocument = FRAME.contentDocument || null
|
|
log('early replay: posting every ' + interval + 'ms until the app captures it')
|
|
|
|
earlyTimer = setInterval(function () {
|
|
// The capture and the deadline are both tested before the frame guard: a
|
|
// detached or mid-navigation frame leaves contentWindow null, and a bare
|
|
// return there would skip them and keep this timer alive for the life of
|
|
// the page. A capture only counts once something has been posted in this
|
|
// replay, so a buffer left by the previous load of the same frame cannot
|
|
// be read as one that belongs to this mount.
|
|
if (attempts > 0 && frameBuffered() && FRAME.contentDocument !== mountDocument) {
|
|
log('early replay: the app captured the message after ' + attempts + ' post(s)')
|
|
stopEarlyReplay()
|
|
return
|
|
}
|
|
|
|
if (Date.now() > deadline) {
|
|
log(
|
|
captureProbeVisible()
|
|
? 'early replay: gave up after ' + attempts + ' post(s), no capture seen'
|
|
: 'early replay: gave up after ' + attempts + ' post(s) - the app does not expose window.__vaLastMessage, so this replay cannot tell whether it captured'
|
|
)
|
|
if (attempts === 0) {
|
|
log('early replay: the frame was never postable, so nothing was sent')
|
|
}
|
|
stopEarlyReplay()
|
|
return
|
|
}
|
|
|
|
if (!FRAME.contentWindow) return
|
|
attempts++
|
|
postToFrame(payload)
|
|
}, interval)
|
|
}
|
|
|
|
function mount(payload, timing) {
|
|
frameLoaded = false
|
|
pendingPayload = payload
|
|
pendingTiming = timing
|
|
stopEarlyReplay()
|
|
|
|
var sandbox = report.sandbox || defaultSandbox
|
|
var url = childUrl()
|
|
var sameUrl = FRAME.getAttribute('src') === url
|
|
|
|
if (sandbox) FRAME.setAttribute('sandbox', sandbox)
|
|
else FRAME.removeAttribute('sandbox')
|
|
|
|
log('mounting ' + url)
|
|
log('sandbox: ' + (sandbox || '(none)'))
|
|
|
|
if (sameUrl) {
|
|
// Re-applying with the same prompts yields the same URL, and setting a
|
|
// frame's src to the value it already has does not reload it - which
|
|
// would post nothing in 'load' mode and leave the previous state on
|
|
// screen. Reload explicitly instead.
|
|
try {
|
|
FRAME.contentWindow.location.reload()
|
|
} catch (error) {
|
|
FRAME.setAttribute('src', 'about:blank')
|
|
FRAME.setAttribute('src', url)
|
|
}
|
|
} else {
|
|
FRAME.setAttribute('src', url)
|
|
}
|
|
|
|
if (timing === 'early' || timing === 'both') {
|
|
log('timing: ' + timing)
|
|
startEarlyReplay(payload)
|
|
} else {
|
|
// timing=load delivers on the frame's own load event, which is the
|
|
// metadata path: the app has bootstrapped and reads the message there.
|
|
// Without this the mode advertised in the controls posts nothing at all.
|
|
log('timing: load - the message goes in when the frame has loaded')
|
|
}
|
|
}
|
|
|
|
/** Post once into the frame that is already up - a prompt change. */
|
|
function postOnce(payload) {
|
|
if (!frameLoaded) {
|
|
log('the frame has not loaded yet, so a prompt change has nowhere to land')
|
|
return
|
|
}
|
|
|
|
postToFrame(payload)
|
|
log('posted one message: ' + JSON.stringify(payload).slice(0, 200))
|
|
}
|
|
|
|
function apply() {
|
|
if (!report) return
|
|
|
|
var payload = message()
|
|
var timing = timingSelect.value
|
|
|
|
log('prompts: ' + JSON.stringify(payload.parameters))
|
|
|
|
if (modeSelect.value === 'prompt') postOnce(payload)
|
|
else mount(payload, timing)
|
|
}
|
|
|
|
function renderPrompts() {
|
|
PROMPTS.textContent = ''
|
|
|
|
promptNames().forEach(function (parameter) {
|
|
var label = document.createElement('label')
|
|
label.textContent = parameter.label
|
|
|
|
var select = document.createElement('select')
|
|
select.setAttribute('data-name', parameter.name)
|
|
|
|
;(parameter.options || []).forEach(function (option) {
|
|
var element = document.createElement('option')
|
|
element.value = option
|
|
element.textContent = option
|
|
if (option === parameter.default) element.selected = true
|
|
select.appendChild(element)
|
|
})
|
|
|
|
label.appendChild(select)
|
|
PROMPTS.appendChild(label)
|
|
})
|
|
}
|
|
|
|
FRAME.addEventListener('load', function () {
|
|
frameLoaded = true
|
|
|
|
// A capture that lands in the last few milliseconds before load would never
|
|
// be seen by the interval, and the log would read as though the early path
|
|
// failed when it had not. Check once here, and log the capture BEFORE the
|
|
// load line so the ordering in the log is the ordering that happened.
|
|
if (earlyTimer !== null && frameBuffered() && FRAME.contentDocument !== mountDocument) {
|
|
log(
|
|
'early replay: the app captured the message after ' + attempts + ' post(s)'
|
|
)
|
|
}
|
|
|
|
stopEarlyReplay()
|
|
log('frame loaded')
|
|
|
|
// timing=load and timing=both deliver on load as well: the app is up, so
|
|
// this is the metadata path, and for 'both' it is the second copy VA sends.
|
|
// timing=early deliberately stops at the captured copy.
|
|
if (pendingTiming !== 'early' && pendingPayload) {
|
|
// Taken, not read: a later load event on the same frame - the app
|
|
// navigating, or a self-reload - must not re-post a filter that belonged
|
|
// to the mount which has already had its delivery.
|
|
var delivered = pendingPayload
|
|
var deliveredTiming = pendingTiming
|
|
pendingPayload = null
|
|
pendingTiming = null
|
|
|
|
postToFrame(delivered)
|
|
log(
|
|
'posted on load (timing=' +
|
|
deliveredTiming +
|
|
'): ' +
|
|
JSON.stringify(delivered).slice(0, 200)
|
|
)
|
|
}
|
|
})
|
|
|
|
window.addEventListener('message', function (event) {
|
|
if (event.source !== FRAME.contentWindow) return
|
|
log(
|
|
'from frame: ' +
|
|
(typeof event.data === 'object'
|
|
? JSON.stringify(event.data)
|
|
: String(event.data)
|
|
).slice(0, 300)
|
|
)
|
|
})
|
|
|
|
document.getElementById('apply').addEventListener('click', apply)
|
|
document.getElementById('clear').addEventListener('click', function () {
|
|
LOG.textContent = ''
|
|
})
|
|
|
|
fetch('report.json')
|
|
.then(function (response) {
|
|
if (!response.ok) throw new Error('report.json answered ' + response.status)
|
|
return response.json()
|
|
})
|
|
.then(function (config) {
|
|
report = config
|
|
targetOrigin = frameOrigin()
|
|
if (!targetOrigin) {
|
|
log('report.server is not a URL, so nothing can be posted: ' + JSON.stringify(report.server))
|
|
} else if (window.location.origin !== targetOrigin) {
|
|
// the probe reads the frame's own global, which a cross-origin frame
|
|
// does not expose - say so rather than logging a capture that never was
|
|
log('warning: this page is on ' + window.location.origin + ' and the frame on ' + targetOrigin + ' - the capture probe cannot read across origins')
|
|
}
|
|
heading.textContent = report.title || 'VA report emulation'
|
|
document.title = report.title || 'VA report emulation'
|
|
timingSelect.value = report.timing || 'both'
|
|
modeSelect.value = report.mode || 'refresh'
|
|
renderPrompts()
|
|
log('report: ' + (report.title || '(untitled)') + ' -> ' + report.app)
|
|
|
|
// A page that renders a report should show one without a click, the way
|
|
// opening the report does.
|
|
apply()
|
|
})
|
|
.catch(function (error) {
|
|
log('could not load report.json: ' + error.message)
|
|
})
|
|
})()
|