JavaScript Patterns
This page collects practical, copy-able JavaScript patterns for HTML blocks and page JavaScript snippets. For the full API surface, see the HTML block JavaScript API and the System JavaScript API.
Note
These patterns apply to standard HTML blocks, which share the page's zPortal and currentBlock objects. An Isolated HTML block runs in an iframe and does not have access to zPortal or currentBlock — load everything such a block needs inside the iframe instead.
Loading external resources
Load an external JavaScript library
Use zPortal.resources.load() to add an external script to the page. It accepts a URL (or an array of URLs), injects a <script> element, de-duplicates by URL so the same file is only loaded once, and returns a Promise that resolves when the script has loaded.
<div class="chart"></div>
<script>
zPortal.resources.load([
'https://cdn.amcharts.com/lib/5/index.js',
'https://cdn.amcharts.com/lib/5/xy.js',
'https://cdn.amcharts.com/lib/5/themes/Animated.js'
]).then(function () {
var root = am5.Root.new(currentBlock.$element.find('.chart')[0]);
root.setThemes([am5themes_Animated.new(root)]);
// ... build your chart ...
});
</script>
Libraries that use AMD/UMD
Some libraries (ECharts, Chart.js, Plotly, Highcharts, …) detect the page's module loader and register themselves with it instead of as a global, so am5/echarts/etc. ends up undefined. Temporarily hide the loader while the library evaluates, then restore it:
<div class="chart" style="width:100%;height:400px;"></div>
<script>
async function loadLib(src, globalName) {
if (window[globalName]) return; // already loaded
var savedDefine = window.define;
window.define = undefined; // hide AMD before the script runs
try {
await zPortal.resources.load(src);
} finally {
window.define = savedDefine; // restore it (the editor needs it)
}
}
(async function () {
await loadLib('https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js', 'echarts');
var el = currentBlock.$element.find('.chart')[0];
var prior = echarts.getInstanceByDom(el);
if (prior) prior.dispose(); // dispose before re-init — block JS can re-run
echarts.init(el).setOption({ /* ... */ });
})();
</script>
External CSS and fonts
zPortal.resources.load() loads JavaScript only. For a stylesheet or web font, add a <link> element yourself:
<script>
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Inter&display=swap';
document.head.appendChild(link);
</script>
Portal-hosted assets
Files uploaded to the Asset Manager are served at /auth/asset_manager/files/<full_path>.
Reference one directly — users signed in to the portal can load it as-is:
<img src="/auth/asset_manager/files/images/branding/logo.png" alt="Logo" />
To resolve an asset dynamically, list a folder with zPortal.assets.fetch() and build the URL from each item's full_path:
<img class="logo" />
<script>
zPortal.assets.fetch('images/branding').then(function (result) {
var item = result.items.find(function (i) { return i.name === 'logo.png'; });
if (item) {
currentBlock.$element.find('.logo')
.attr('src', '/auth/asset_manager/files/' + item.full_path);
}
});
</script>
You can also pass an Asset Manager URL to zPortal.resources.load() to load a JavaScript file you host in the portal.
Using query results in an HTML block
Configure one or more queries on the block's Query tab, then read their results from currentBlock.queryResults.
Accessing results
Each entry in currentBlock.queryResults is one query's result and exposes three properties:
mappedData— an array of row objects keyed by column name. Use this to iterate rows.columns— the array of column-name strings.data— the raw rows as positional arrays. Accessing a column by name (data.region) returns that column's values across all rows — handy for libraries that want parallel arrays.
// SELECT region, sales FROM ...
currentBlock.queryResults[0].columns // ["region", "sales"]
currentBlock.queryResults[0].mappedData // [{region: "Central", sales: 1000}, ...]
currentBlock.queryResults[0].data // [["Central", 1000], ["East", 1200], ...]
currentBlock.queryResults[0].data.region // ["Central", "East", ...]
Use mappedData to iterate rows by name; use data.<column> when a library wants a whole column as an array.
Warning
data rows are positional arrays, so data.map(row => row.region) returns undefined. Use mappedData to read values by column name.
Signal when the block is done
Call currentBlock.getOnLoadedCallback() to tell the portal the block has finished rendering, so it can hide the block's loading indicator and time page exports correctly. Get the callback early, and call it exactly once after the UI is drawn. For asynchronous work, call it in a finally block so it always fires:
async function render() {
var done = currentBlock.getOnLoadedCallback();
try {
// ... async work, then draw the UI ...
} catch (err) {
console.error(err);
} finally {
done();
}
}
Scope the DOM to your block
currentBlock.$element is a jQuery object for the block's root element. Scope every lookup to it, and never use id attributes — the same block can appear more than once on a page, so ids collide. Use classes instead:
// Wrong: document.getElementById('results') / $('#results')
// Right:
currentBlock.$element.find('.results tbody');
Single-query block
With one query, the script runs once after the query loads:
<table>
<thead><tr><th>Name</th><th>Value</th></tr></thead>
<tbody class="results"></tbody>
</table>
<script>
var done = currentBlock.getOnLoadedCallback();
var $body = currentBlock.$element.find('.results');
currentBlock.queryResults[0].mappedData.forEach(function (row) {
$body.append('<tr><td>' + row.name + '</td><td>' + row.value + '</td></tr>');
});
done();
</script>
Multi-query block
A block's script runs once per query load, so with several queries it can run before all of them have data. Map each query to its index, return early until every required query has loaded, then render:
<div class="report"></div>
<script>
// 1. Get the loaded callback immediately
var done = currentBlock.getOnLoadedCallback();
// 2. Map each query to its index on the Query tab
var QUERY_INDEX = { conditions: 0, repairs: 1 };
// 3. Return until every required query has loaded
var allLoaded = Object.values(QUERY_INDEX).every(function (i) {
var q = currentBlock.queryResults && currentBlock.queryResults[i];
return q && q.data !== undefined;
});
if (!allLoaded) { return; }
// 4. All queries are loaded — render, then signal completion
var conditions = currentBlock.queryResults[QUERY_INDEX.conditions].mappedData;
var repairs = currentBlock.queryResults[QUERY_INDEX.repairs].mappedData;
// ... build UI into currentBlock.$element.find('.report') ...
done();
</script>
Fetch a query outside the block lifecycle
To pull query data on demand — for example in response to a click — get the query by ID, register an onLoad handler, and fetch:
zPortal.query.getQuery({ queryID: 'your-query-id' }).then(function (query) {
query.onLoad(function () {
var rows = query.getData();
// ... use rows ...
});
query.fetchData();
});
Apply filters across the page
Set a global filter from your JavaScript to drive the queries on other blocks. Filters apply to every active query with a matching column:
zPortal.query.setFilter('region', ['Central']); // filter on a column
zPortal.query.removeFilter('region'); // remove one filter
zPortal.query.clearFilters(); // remove all filters
For a date range, use zPortal.query.setRangeFilter('created_at', { start: '2026-01-01T00:00:00Z', end: '2026-12-31T23:59:59Z' }).
Full example: an amCharts 5 chart from multiple queries
This example follows the portal's standard block structure — obtain the loaded callback, map the queries, return early until they've loaded, then call an entry-point function, with rendering split into its own function that receives the data. It also starts the amCharts download up front so the library loads alongside the queries (index.js first, since the other modules depend on the am5 global it defines).
Configure two queries on the block's Query tab: query 0 returns region, sales; query 1 returns region, target.
<div class="sales-chart" style="width: 100%; height: 100%;"></div>
<script>
(function () {
// 1. Obtain the loaded callback first
const loadedCallback = currentBlock.getOnLoadedCallback();
// 2. Map descriptive names to query indices (Query-tab order)
const QUERY_INDEX = { sales: 0, targets: 1 };
// 3. Kick off the amCharts download now so it loads alongside the queries
// (resources.load() de-dupes, so the await in createChart is instant).
await loadLibraries();
// 4. Return early until every required query has loaded
const allLoaded = Object.values(QUERY_INDEX).every(idx => {
const result = currentBlock.queryResults && currentBlock.queryResults[idx];
return result && result.data !== undefined;
});
if (!allLoaded) { return; }
// 5. All queries loaded — build the chart
main();
// --- Function definitions ---
// Entry-point — reads query data, calls chart renderer, signals done
function main() {
const sales = currentBlock.queryResults[QUERY_INDEX.sales].mappedData;
const targets = currentBlock.queryResults[QUERY_INDEX.targets].mappedData;
const targetByRegion = {};
targets.forEach(row => { targetByRegion[row.region] = Number(row.target); });
const data = sales.map(row => ({
region: row.region,
sales: Number(row.sales),
target: targetByRegion[row.region]
}));
try {
renderChart(data);
} catch (error) {
console.error('Error building chart:', error);
} finally {
loadedCallback(); // fires once, after the chart is drawn or on error
}
}
// Rendering function — receives prepared data, never fetches
function renderChart(data) {
const el = currentBlock.$element.find('.sales-chart')[0];
// Dispose any chart left over from a previous run of this block
am5.registry.rootElements
.filter(r => r && r.dom === el)
.forEach(r => r.dispose());
const root = am5.Root.new(el);
root.setThemes([am5themes_Animated.new(root)]);
const chart = root.container.children.push(am5xy.XYChart.new(root, {
panX: false,
panY: false,
layout: root.verticalLayout
}));
const xAxis = chart.xAxes.push(am5xy.CategoryAxis.new(root, {
categoryField: 'region',
renderer: am5xy.AxisRendererX.new(root, {})
}));
xAxis.data.setAll(data);
const yAxis = chart.yAxes.push(am5xy.ValueAxis.new(root, {
renderer: am5xy.AxisRendererY.new(root, {})
}));
['sales', 'target'].forEach(field => {
const series = chart.series.push(am5xy.ColumnSeries.new(root, {
name: field,
xAxis: xAxis,
yAxis: yAxis,
valueYField: field,
categoryXField: 'region'
}));
series.data.setAll(data);
series.appear(1000);
});
const legend = chart.children.push(am5.Legend.new(root, {}));
legend.data.setAll(chart.series.values);
chart.appear(1000, 100);
}
// amCharts 5 needs index.js (which defines `am5`) to load before the modules
// that depend on it, so load it first, then the rest in parallel.
async function loadLibraries() {
await zPortal.resources.load('https://cdn.amcharts.com/lib/5/index.js');
await Promise.all([
zPortal.resources.load('https://cdn.amcharts.com/lib/5/xy.js'),
zPortal.resources.load('https://cdn.amcharts.com/lib/5/percent.js'),
zPortal.resources.load('https://cdn.amcharts.com/lib/5/map.js'),
zPortal.resources.load('https://cdn.amcharts.com/lib/5/geodata/worldLow.js'),
zPortal.resources.load('https://cdn.amcharts.com/lib/5/themes/Animated.js'),
]);
}
})();
</script>