Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d4155dc6c | ||
|
|
88b5b3071a | ||
|
|
2185db911d |
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
|
})
|
||||||
|
})()
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user