Compare commits

..
Author SHA1 Message Date
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 702 additions and 17 deletions

No files matched your search

+10 -9
View File
@@ -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)
+2 -8
View File
@@ -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.
+84
View File
@@ -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.
+182
View File
@@ -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
View File
@@ -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)
})
})()
+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>
+29
View File
@@ -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"
}
]
}