Compare commits

..
Author SHA1 Message Date
dc 7d4155dc6c fix(va-host): deliver in load mode, honour dataType, and survive odd prompt names
Build / Build-and-ng-test (pull_request) Waiting to run
Lighthouse Checks / lighthouse (pull_request) Waiting to run
Review findings on the harness.

- timing=load posted nothing: mount() only started a replay for early/both and
  the load handler only logged. Both and load now post on the frame's load as
  well, which is the metadata path and the second copy VA sends, so the mode the
  controls offer is a mode that works.
- a re-apply with the same prompts did not reload the frame: setting src to the
  value it already has is a no-op in the browser, so the new load post (and the
  refresh path generally) delivered nothing. The frame is reloaded explicitly
  when the URL is unchanged.
- dataType was declared and never applied. A prompt configured as number now
  arrives as 42 rather than "42", which is what DC's clause builder branches on;
  a non-numeric value is left alone rather than becoming NaN.
- the prompt select is found by walking the selects instead of building a
  [data-name="..."] selector, so a prompt name with a quote or bracket in it
  cannot throw inside apply() and leave the page with no frame.
- the replay's give-up message now says when the app does not expose
  window.__vaLastMessage, so a changed probe is not reported as a delivery
  failure.
- deploy.mjs throws when the Node runtime cannot read Set-Cookie rather than
  silently dropping every cookie and failing later as an unauthenticated 401.

report.json carries a numeric prompt so the numeric path is exercised by
default rather than only in theory.
2026-11-03 19:41:56 +00:00
dc 88b5b3071a Merge remote-tracking branch 'origin/release/7.22' into feat/va-report-emulation 2026-11-03 19:38:03 +00:00
dc 2185db911d feat(va-host): emulate a VA report that embeds DC, as a deployable AppStream app
Build / Build-and-ng-test (pull_request) Successful in 5m6s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m31s
Build / Build-and-test-development (pull_request) Successful in 35m30s
A parent page standing in for the VA report that embeds Data Controller as
data-driven content: the dropdowns are the report's prompts, and the iframe is
framed and fed the way VA frames and feeds it.

Deliberately outside 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.

- report.json defines the report: child AppStream name, table, sandbox set,
  delivery mode and timing, and the prompts (label is the va_parmap key, since
  DC matches on the label rather than the report's internal parameter name).
- two delivery modes, because they reach different code in DC: refresh (reload
  the frame, replay from before load - the report-refresh path) and prompt (post
  into the loaded frame - an in-place prompt change).
- deploy.mjs posts the folder to a SASjs Server with /SASjsApi/drive/deploy and
  streamWebFolder, which is the same route sasjs cbd uses, so a static app is
  published the usual way and served at /AppStream/<name>.
2026-11-03 16:19:57 +00:00
7 changed files with 818 additions and 21 deletions

No files matched your search

+12 -13
View File
@@ -10,16 +10,7 @@
# target table; it runs early, before other specs have added rows to the # target table; it runs early, before other specs have added rows to the
# approval tables. # approval tables.
# - metanav only reads the metadata tree, and applies a licence key so it can # - metanav only reads the metadata tree, and applies a licence key so it can
# run on its own; it runs after every spec that asserts a free-tier or # run on its own; it goes last so that key cannot change another spec.
# unlicensed state (csv-limited first), because that key removes the "Locked
# Feature" modal. Anything placed after it must not depend on the absence of
# a licence - va-ddc-host is the one that does, and it asserts nothing about
# licensing.
# - va-ddc-host goes last: it clears the DC_JSLIB filter tables
# (mpe_filtersource, mpe_filteranytable, mpe_maxkeyvalues) before each case,
# and every spec in this job shares one mock estate, so it has to run after
# everything that reads or writes them. See resetFilterStore in
# client/cypress/e2e/va-ddc-host.cy.ts.
# #
# Specs added later are appended rather than inserted, so the sequence that was # Specs added later are appended rather than inserted, so the sequence that was
# known green keeps its shape. # known green keeps its shape.
@@ -34,12 +25,20 @@
# reordering the sequence that was known green. # reordering the sequence that was known green.
set -euo pipefail set -euo pipefail
SPECS="csv-limited hook-programs liveness editor excel-multi-load excel csv filtering filter-panel licensing viewer-labels full-table-search viewbox stage browser-info ddl-export row-cell-limits derived-variables startupservice-launcher coltype-delete-record submitted-details embed-va download metanav va-ddc-host" SPECS="csv-limited hook-programs liveness editor excel-multi-load excel csv filtering filter-panel licensing viewer-labels full-table-search viewbox stage browser-info ddl-export row-cell-limits derived-variables startupservice-launcher coltype-delete-record submitted-details embed-va download metanav"
# Specs that exist but are held out of CI, each with the reason. Listed here so # Specs that exist but are held out of CI, each with the reason. Listed here so
# the coverage check below can tell a deliberate exclusion from a forgotten file. # the coverage check below can tell a deliberate exclusion from a forgotten file.
# Nothing is held out at present. # va-ddc-host drives the app through sasjs/server's /ddc-host page with
EXCLUDED="" # `timing=early`, which needs a server release that carries it - the version this
# job pins does not, so the spec cannot run until SASJS_SERVER_VERSION moves.
# https://git.datacontroller.io/dc/dc/issues/449 tracks re-enabling it.
#
# When it is re-enabled it has to run LAST, or be given its own estate: it clears
# the DC_JSLIB filter tables (mpe_filtersource, mpe_filteranytable,
# mpe_maxkeyvalues) before each case, and every spec in this job shares one mock
# estate. See resetFilterStore in client/cypress/e2e/va-ddc-host.cy.ts.
EXCLUDED="va-ddc-host"
case "${1:-}" in case "${1:-}" in
all) all)
+2 -8
View File
@@ -3,10 +3,8 @@ run-name: Running Lint Check and Licence checker on Pull Request
on: [pull_request] on: [pull_request]
env: env:
# the server the mocked services and app tests run against - bump deliberately. # the server the mocked services and app tests run against - bump deliberately
# 3.12.0 is the floor: /ddc-host carries `timing=early` from there, which SASJS_SERVER_VERSION: '3.6.2'
# client/cypress/e2e/va-ddc-host.cy.ts needs.
SASJS_SERVER_VERSION: '3.12.1'
NODE_VERSION: '24.15.0' NODE_VERSION: '24.15.0'
jobs: jobs:
@@ -310,10 +308,6 @@ jobs:
echo CORS=enable >> .env echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .env echo WHITELIST=http://localhost:4200 >> .env
echo HTTP_PORT=5000 >> .env echo HTTP_PORT=5000 >> .env
# /ddc-host is a testing tool, so the server answers 404 for it unless
# this is set. va-ddc-host.cy.ts drives the app through that page with
# timing=early.
echo DDC_HOST_ENABLED=true >> .env
# The server refuses to run as root unless RUN_AS names an account # The server refuses to run as root unless RUN_AS names an account
# (a security change in the recent releases). This job runs as root # (a security change in the recent releases). This job runs as root
# in a throwaway container, so opt in explicitly. # in a throwaway container, so opt in explicitly.
+95
View File
@@ -0,0 +1,95 @@
# VA report emulation (host for the embedded DC app)
A parent page that stands in for a SAS Visual Analytics report which embeds Data Controller as data-driven content. The dropdowns are the report's prompts; the iframe is framed the way VA frames the object, and is fed the same DDC message VA sends.
Two things this is not:
- **It is not 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. Nothing here is in `frontend.zip` or `sasjs_server.json.zip`.
- **It is not a substitute for a real VA report.** It reproduces the contract DC sees: the message shape, the delivery timing, the sandbox attribute set and the `embed=va` / `va_parmap` URL contract. It does not reproduce VA's UI or how VA computes a prompt's value.
## Files
- `index.html` - the report page: prompt bar, delivery/timing controls, the iframe, and a log.
- `host.js` - reads the config, builds the message, mounts the frame, replays the message early, posts prompt changes.
- `report.json` - the report definition (below). The committed copy points at a local server for local runs; `deploy.mjs` rewrites it for the target it deploys to.
- `deploy.mjs` - deploys the three files above to a SASjs Server as an AppStream app.
## report.json
```json
{
"title": "VA report emulation - Data Controller embed",
"server": "http://localhost:5000",
"app": "clickme",
"table": "DC_JSLIB.MPE_X_TEST",
"mode": "refresh",
"timing": "both",
"sandbox": "allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals",
"intervalMs": 50,
"replayTimeoutMs": 30000,
"parameters": [
{
"name": "pr1",
"label": "Some dropdown",
"dataType": "string",
"column": "SOME_DROPDOWN",
"options": ["Option 1", "Option 2", "Option 3"],
"default": "Option 2"
}
]
}
```
- `app` is the **AppStream name** of the DC deployment, not its app-loc: on a server with several names for one folder (`DataController`, `DataController_Main`, ...) any of them serves the same build, and the name is what the URL needs.
- `table` goes into the editor route: `#/editor/<table>`.
- `parameters[].label` is the mapping key. DC matches `va_parmap` entries against the parameter **label** (not `name`, which is the report's own bookkeeping), case-insensitively, splitting each entry at its last `-`. `column` here becomes `va_parmap=<label>-<column>`; a parameter with no `column` is sent to the app but not mapped.
- `dataType` is `string` or `number` and is preserved in the message: a numeric prompt and a month-name date string take different paths in DC's clause builder.
- `mode` is `refresh` (reload the frame per apply - the report-refresh path) or `prompt` (post into the loaded frame - an in-place prompt change). `timing` is `both` (default), `early` or `load`: `early` posts only before the frame loads, `load` only after it has, and `both` does each, which is what VA does for a report with a stored prompt.
## What the app receives
The message is the DDC shape VA sends, one entry per prompt, and `dataType` is honoured rather than cosmetic: a prompt configured as `number` arrives as `42`, not `"42"`, because DC's clause builder branches on the type and a numeric column compared with a quoted value is the failure this pair exists to catch. A prompt whose configured value is not numeric is left as it is rather than becoming `NaN`.
## Deploying
```bash
# local, desktop-mode server (no login)
node deploy.mjs --server http://localhost:5000 --app clickme --table DC_JSLIB.MPE_X_TEST
# a server-mode estate (local login; credentials from --user/--pass or SASJS_USER/SASJS_PASS)
node deploy.mjs --server https://example.com --app DataControllerDev \
--table DC996664.MPE_X_TEST --name va-host --apploc /Public/app/va-host
```
The script is the same route `sasjs cbd` uses: `POST /SASjsApi/drive/deploy` with a file tree plus `streamWebFolder: "web"` and `streamServiceName`, after which the server serves the folder at `/AppStream/<name>/`. No services are compiled, because the app is static.
Two things worth knowing about that route:
- **`type: 'file'` members are written base64-decoded; `type: 'service'` members are plain text.** A static app is therefore sent encoded (`deploy.mjs` does this). Sending plain text writes a truncated, unreadable file rather than failing.
- **A redeploy does not remove files the previous version had.** The folder is written over. Deleting `<appLoc>` first, or deploying to a fresh app-loc, is the way to avoid carrying a stale asset.
The AppStream gate is `authenticateAccessToken` plus a per-app grant on `/AppStream/<name>`, so on a server-mode estate the page answers 401 until the user is signed in - which is also what makes the embedded app work, since both are served from the same origin and the session cookie is same-site.
## Why same origin
On the deployments this emulates, the VA report and the DC app share a domain. That is the topology to reproduce: a host on a *different* origin would be blocked by the server's `X-Frame-Options: SAMEORIGIN` and `frame-ancestors 'self'`, and the `SameSite=Strict` session cookie would not travel into the frame. DC's own trust rules agree - `VaMessagingService.isTrustedEarlyOrigin` accepts the embedding window's own origin, and the live path accepts a message whose `event.source` is the parent frame.
## What to look for in the log
- `early replay: the app captured the message after N post(s)` before `frame loaded` - the pre-bootstrap path, which is the one a report refresh exercises and the one that a single post on load cannot reach.
- `posted on load (timing=both)` after `frame loaded` - the second copy VA sends, read on the app's ordinary path.
- With `timing=load`: no replay lines at all, then `frame loaded` followed by `posted on load (timing=load)`.
- With `mode=prompt`: `posted one message:` and no `mounting` line - the metadata path, for an in-place prompt change.
- If the replay gives up and also says the app does not expose `window.__vaLastMessage`, the probe has changed rather than the delivery: that global is how the replay knows to stop early.
In the embedded app, the FILTER bar shows the clause the report produced, e.g.
```
FILTER : (( SOME_DROPDOWN = 'Option 2' AND SOME_DATE = '12FEB1960'd AND SOME_NUM = 42 ))
```
A date arriving as a character value instead of a date literal, or a number arriving quoted, is the defect this pair exists to catch.
## Nothing here reaches a DC release artefact
`frontend.zip` is built from `client/dist` and `sasjs_server.json.zip` from `sasjs/sasjsbuild`; `va-host/` is outside both trees and is not referenced by `sasjsconfig.json`. Checked by building them: `sasjs c -t server && sasjs b -t server` produces a `server.json` file tree of 100 members (40 under `/services/web`) with no `va-host` entry, and `client/dist` holds 40 files, none of them `va-host`. The separation is a property of the build inputs, not a convention someone has to remember.
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
/*
* Deploy the VA report emulation to a SASjs Server as an AppStream app.
*
* This is the same route `sasjs cbd` uses: POST /SASjsApi/drive/deploy with a
* file tree, plus `streamWebFolder` so the server registers the folder as an
* app and serves it at /AppStream/<name>. No services are compiled - the app is
* static, and the server's AppStream feature is exactly the "usual way" to
* publish one.
*
* The local `report.json` is rewritten for the target, so the deployed page
* points at the server and app it was deployed beside, rather than at the
* localhost defaults the repo carries for local runs.
*
* usage:
* node deploy.mjs --server https://sas.4gl.io --app dc --table DC996664.MPE_X_TEST \
* [--name va-host] [--apploc /Public/app/va-host] [--user X --pass Y]
*
* Credentials come from --user/--pass or SASJS_USER/SASJS_PASS. In desktop mode
* the server needs none.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const args = process.argv.slice(2)
const arg = (name, fallback) => {
const index = args.indexOf(`--${name}`)
return index >= 0 && args[index + 1] ? args[index + 1] : fallback
}
const server = arg('server', 'http://localhost:5000').replace(/\/$/, '')
const name = arg('name', 'va-host')
const appLoc = arg('apploc', `/Public/app/${name}`)
const user = arg('user', process.env.SASJS_USER)
const pass = arg('pass', process.env.SASJS_PASS)
const jar = new Map()
const rememberCookies = (response) => {
// Node only exposes getSetCookie from 18.14. Feature-detecting it silently
// would drop every cookie and turn the failure into an unauthenticated 401
// from the deploy call, with nothing pointing at the cause.
if (typeof response.headers.getSetCookie !== 'function') {
throw new Error(
`this Node (${process.version}) cannot read Set-Cookie headers - deploy from Node 18.14 or later`
)
}
const headers = response.headers.getSetCookie()
for (const header of headers) {
const [pair] = header.split(';')
const index = pair.indexOf('=')
if (index < 0) continue
const key = pair.slice(0, index).trim()
const value = pair.slice(index + 1).trim()
if (value === '') jar.delete(key)
else jar.set(key, value)
}
}
const cookieHeader = () =>
Array.from(jar.entries())
.map(([key, value]) => `${key}=${value}`)
.join('; ')
const xsrf = () => jar.get('XSRF-TOKEN') || ''
const request = async (url, options = {}) => {
const response = await fetch(url, {
...options,
headers: {
...(options.headers || {}),
cookie: cookieHeader(),
...(xsrf() ? { 'X-XSRF-TOKEN': xsrf() } : {})
}
})
rememberCookies(response)
return response
}
const login = async () => {
await request(`${server}/`)
// Desktop mode refuses a login ("Not Allowed while in Desktop Mode") and does
// not gate /AppStream either, so ask the server which mode it is in rather
// than guessing from the presence of credentials in the environment.
const info = await request(`${server}/SASjsApi/info`)
const mode = info.ok ? (await info.json()).mode : undefined
if (mode === 'desktop') {
console.log('desktop mode - no login needed')
return
}
if (!user || !pass) {
throw new Error(
`server is in ${mode || 'unknown'} mode and no credentials were given`
)
}
const response = await request(`${server}/SASLogon/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
})
if (!response.ok) {
throw new Error(`login failed: ${response.status} ${await response.text()}`)
}
console.log(`logged in as ${user}`)
}
/** The deployed config: the repo's report.json, aimed at this target. */
const report = () => {
const base = JSON.parse(fs.readFileSync(path.join(here, 'report.json'), 'utf8'))
return {
...base,
server,
app: arg('app', base.app),
table: arg('table', base.table),
mode: arg('mode', base.mode),
timing: arg('timing', base.timing)
}
}
// `type: 'file'` members are written with base64 decoding (services are plain
// text), so anything that is not SAS code goes over the wire encoded. See the
// README: this is the one sharp edge of the app-deploy route.
const file = (name, content) => ({
name,
type: 'file',
code: Buffer.from(content, 'utf8').toString('base64')
})
const fileTree = (config) => ({
members: [
{
name: 'services',
type: 'folder',
members: [
{
name: 'web',
type: 'folder',
members: [
file('index.html', fs.readFileSync(path.join(here, 'index.html'), 'utf8')),
file('host.js', fs.readFileSync(path.join(here, 'host.js'), 'utf8')),
file('report.json', JSON.stringify(config, null, 2))
]
}
]
}
]
})
const deploy = async () => {
await login()
const config = report()
const response = await request(`${server}/SASjsApi/drive/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
appLoc,
// The folder the server streams; must be one path segment.
streamWebFolder: 'web',
streamServiceName: name,
fileTree: fileTree(config)
})
})
const body = await response.text()
if (!response.ok) throw new Error(`deploy failed: ${response.status} ${body}`)
console.log(body)
console.log(`app: ${server}/AppStream/${name}/`)
console.log(`child: ${config.server}/AppStream/${config.app}/`)
}
deploy().catch((error) => {
console.error(error.message)
process.exit(1)
})
+367
View File
@@ -0,0 +1,367 @@
/*
* 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'
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
// targetOrigin '*': harmless here (the message is a filter, not a secret) and
// required when the frame's origin differs from this page's.
target.postMessage(payload, '*')
}
/** 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
log('early replay: posting every ' + interval + 'ms until the app captures it')
earlyTimer = setInterval(function () {
if (!FRAME.contentWindow) return
attempts++
postToFrame(payload)
if (frameBuffered()) {
log('early replay: the app captured the message after ' + attempts + ' post(s)')
stopEarlyReplay()
} else 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'
)
stopEarlyReplay()
}
}, 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()) {
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) {
postToFrame(pendingPayload)
log(
'posted on load (timing=' +
pendingTiming +
'): ' +
JSON.stringify(pendingPayload).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
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)
})
})()
+116
View File
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VA report emulation</title>
<!--
The report config lives next to this page. It is fetched rather than
inlined so a report can be changed without touching the page, and because
the SASjs Server CSP allows scripts from 'self' only (an inline script, or
an onclick attribute, is refused by the browser).
-->
<script src="host.js" defer></script>
<style>
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 16px 20px 40px;
color: #1c1c1c;
background: #f6f7f9;
}
h1 {
font-size: 18px;
margin: 0 0 4px;
}
.hint {
margin: 0 0 12px;
color: #555;
font-size: 12px;
}
#prompts {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
padding: 12px;
background: #fff;
border: 1px solid #dfe3e8;
border-radius: 6px;
}
#prompts label {
display: flex;
flex-direction: column;
font-size: 12px;
gap: 4px;
}
select,
button {
font: inherit;
font-size: 13px;
padding: 4px 6px;
}
#controls {
margin: 10px 0;
display: flex;
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
}
#frame {
width: 100%;
height: 620px;
border: 1px solid #dfe3e8;
border-radius: 6px;
background: #fff;
}
#log {
margin-top: 10px;
max-height: 160px;
overflow: auto;
padding: 8px;
font: 12px/1.45 ui-monospace, monospace;
background: #101418;
color: #d6e2ea;
border-radius: 6px;
}
</style>
</head>
<body>
<h1 id="heading">VA report emulation</h1>
<p class="hint">
Parent prompts stand in for the report's dropdowns; the embedded object is
the Data Controller app, framed the way SAS Visual Analytics frames it.
</p>
<div id="prompts"></div>
<div id="controls">
<label>
delivery
<select id="mode">
<option value="refresh">report refresh (reload the frame)</option>
<option value="prompt">prompt change (post into the frame)</option>
</select>
</label>
<label>
timing
<select id="timing">
<option value="both">both</option>
<option value="early">early</option>
<option value="load">load</option>
</select>
</label>
<button id="apply" type="button">Apply prompts</button>
<button id="clear" type="button">Clear log</button>
</div>
<iframe
id="frame"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
title="Data Controller, embedded"
></iframe>
<pre id="log"></pre>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{
"title": "VA report emulation - Data Controller embed",
"server": "http://localhost:5000",
"app": "clickme",
"table": "DC_JSLIB.MPE_X_TEST",
"mode": "refresh",
"timing": "both",
"sandbox": "allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals",
"intervalMs": 50,
"replayTimeoutMs": 30000,
"parameters": [
{
"name": "pr1",
"label": "Some dropdown",
"dataType": "string",
"column": "SOME_DROPDOWN",
"options": ["Option 1", "Option 2", "Option 3"],
"default": "Option 2"
},
{
"name": "pr2",
"label": "Some date",
"dataType": "string",
"column": "SOME_DATE",
"options": ["12FEB1960", "01DEC2021", "August 02, 1971"],
"default": "12FEB1960"
},
{
"name": "pr3",
"label": "Some number",
"dataType": "number",
"column": "SOME_NUM",
"options": ["42", "1613.001"],
"default": "42"
}
]
}