Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2185db911d |
No files matched your search
@@ -25,19 +25,20 @@
|
||||
# reordering the sequence that was known green.
|
||||
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
|
||||
# the coverage check below can tell a deliberate exclusion from a forgotten file.
|
||||
# va-ddc-host drives the app through sasjs/server's /ddc-host page with
|
||||
# `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.
|
||||
#
|
||||
# va-ddc-host used to be listed here: it drives the app through sasjs/server's
|
||||
# /ddc-host page with `timing=early`, which arrived in server 3.12.0, and the job
|
||||
# used to pin an older server. It is now last in SPECS instead, and it has to stay
|
||||
# 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=""
|
||||
# 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
|
||||
all)
|
||||
|
||||
@@ -3,10 +3,8 @@ run-name: Running Lint Check and Licence checker on Pull Request
|
||||
on: [pull_request]
|
||||
|
||||
env:
|
||||
# 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
|
||||
# client/cypress/e2e/va-ddc-host.cy.ts needs.
|
||||
SASJS_SERVER_VERSION: '3.12.1'
|
||||
# the server the mocked services and app tests run against - bump deliberately
|
||||
SASJS_SERVER_VERSION: '3.6.2'
|
||||
NODE_VERSION: '24.15.0'
|
||||
|
||||
jobs:
|
||||
@@ -232,10 +230,6 @@ jobs:
|
||||
echo CORS=enable >> .env
|
||||
echo WHITELIST=http://localhost:4200 >> .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
|
||||
# (a security change in the recent releases). This job runs as root
|
||||
# in a throwaway container, so opt in explicitly.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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`.
|
||||
|
||||
## 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.
|
||||
- With `mode=prompt`, `posted one message:` and no `mounting` line - the metadata path, for an in-place prompt change.
|
||||
|
||||
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 ))
|
||||
```
|
||||
|
||||
A date arriving as a character value instead of a date literal is the defect this pair exists to catch.
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/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) => {
|
||||
const headers = response.headers.getSetCookie
|
||||
? 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)
|
||||
})
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
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 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 = PROMPTS.querySelector(
|
||||
'select[data-name="' + parameter.name + '"]'
|
||||
)
|
||||
|
||||
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: parameter.dataType || 'string',
|
||||
value: field ? field.value : parameter.default
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
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('early replay: gave up after ' + attempts + ' post(s), no capture seen')
|
||||
stopEarlyReplay()
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
function mount(payload, timing) {
|
||||
frameLoaded = false
|
||||
stopEarlyReplay()
|
||||
|
||||
var sandbox = report.sandbox || defaultSandbox
|
||||
if (sandbox) FRAME.setAttribute('sandbox', sandbox)
|
||||
else FRAME.removeAttribute('sandbox')
|
||||
|
||||
log('mounting ' + childUrl())
|
||||
log('sandbox: ' + (sandbox || '(none)'))
|
||||
|
||||
FRAME.setAttribute('src', childUrl())
|
||||
|
||||
if (timing === 'early' || timing === 'both') {
|
||||
log('timing: ' + timing)
|
||||
startEarlyReplay(payload)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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')
|
||||
})
|
||||
|
||||
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,29 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user