Portal 1.18.0 Upgrade Guide
Overview
Portal version 1.18.0 introduces changes that may require updates to custom HTML block code and portal configuration. This document covers the breaking changes identified during upgrade testing and provides guidance for migrating existing portals. It is intended to be used as a reference for both manual upgrades and agent-assisted code migration.
Key Deprecations in 1.18.0
Per the official 1.18.0 documentation:
zPortal.dataSource(entire namespace) — Deprecated. UsezPortal.queryinstead.currentBlock.data— Deprecated. UsecurrentBlock.queryResults[0].datainstead. The shortcut only accesses the first query and does not support multiple queries.currentBlock.columns— Deprecated. UsecurrentBlock.queryResults[0].columnsinstead.currentBlock.siteConfig— Deprecated. UsecurrentBlock.configinstead.zPortal.layout— Deprecated. UsezPortal.pageinstead.zPortal.siteConfig— Deprecated. UsezPortal.configinstead.
The following APIs are still current and documented:
zPortal.block.getData(iid),zPortal.block.show(iid),zPortal.block.hide(iid),zPortal.block.on()zPortal.query.getQuery(),zPortal.query.setFilter(),zPortal.query.clearFilters(), etc.currentBlock.queryResults,currentBlock.currentUser,currentBlock.pages,currentBlock.layout,currentBlock.theme,currentBlock.system,currentBlock.config
1. Block Data Access — Timing Change
zPortal.block.getData(blockid) still exists but may no longer work reliably in block code after upgrading. The data may not be ready when the call is made, even though this worked in prior versions.
Before:
let dataRows = zPortal.block.getData(blockid);
After:
let dataRows = currentBlock.queryResults[0].data;
2. Column Name References in Queries
Column names in queries may need to be updated after upgrade. In some cases, column names with spaces and double quotes need to be changed to lowercase with underscores and no quotes.
Before:
"My Column Name"
After:
my_column_name
Note: This may be portal-specific rather than a universal 1.18.0 change. Verify column name formats on a per-portal basis after upgrading.
3. Data Source Column Access — Timing Change
Similar to issue #1, zPortal.dataSource.get(datasourceid).columns may no longer work reliably in block code after upgrade due to timing.
Before:
let columnNames = zPortal.dataSource.get(datasourceid).columns;
After:
const columnNames = currentBlock.queryResults[0].columns;
4. CSV Download / Data Fetching — New Query API
The old approach of iterating through page blocks, accessing data sources, and calling zPortal.dataSource.fetchResults() is deprecated. While fetchResults still works reliably, it should be replaced with the new Query API.
Before:
async function fetchDataAndDownloadCSV() {
try {
let blocks = zPortal.page.data.grid.blocks;
let datasourceId, query;
for (const block of blocks) {
if (block.type === 'data-table') {
datasourceId = block.data.__source__;
query = block.data;
query.limit = "0";
break;
}
}
let datasource = zPortal.dataSource.get(datasourceId);
let filters = Object.assign(
{},
datasource.getFilters(),
datasource.getRangeFilters(),
);
const jsonData = await zPortal.dataSource.fetchResults({
dataSourceId: datasourceId,
filters: filters,
queries: [query]
});
let result = jsonData.results[0];
let headers = result.columns.map(c => c.name);
let data = result.data;
let csvContent = JSONtoCSV(data, headers);
downloadCSV(csvContent);
} catch (error) {
console.error('Error:', error);
}
}
After:
async function fetchDataAndDownloadCSV() {
try {
const queryId = '<query-id>'; // Query the data table uses
zPortal.query.getQuery({
queryID: queryId
}).then(query => {
query.enableFiltering();
query.setPagination(
Object.assign({}, query.pagination, { limit: 0 })
);
// Setup load listener before fetching data
const cleanup = query.onLoad(() => {
const data = query.getData();
let csvContent = JSONtoCSV(data, query.columns);
downloadCSV(csvContent);
});
// Fetch the data
query.fetchData();
});
} catch (error) {
console.error('Error:', error);
}
}
Key differences:
- Uses zPortal.query.getQuery() with a specific query ID instead of iterating through blocks
- Callback-based pattern (query.onLoad) instead of async/await — addresses timing issues
- Filters and data access handled through the Query object rather than the DataSource API
5. Map Block Data — Eliminating Hidden Blocks
Previously, a separate hidden block was commonly used to fetch additional data (e.g., background lines for a map). In 1.18.0, multiple queries can be added directly to a single block, eliminating the need for hidden blocks.
Setup steps: 1. Create the new Query in the block's Query tab in the UI 2. Remove the default page size of 50 from the Query editor so that all data is retrieved without pagination 3. Save it and add it to the block
Data access in the HTML block:
Since JS in an HTML block runs on load of the query data, no listeners are needed — just run the code assuming the data is ready. Use currentBlock.queryResults[index] to access each query's data by its index on the block.
const pointsQueryIndex = 0,
linesQueryIndex = 1;
let dataRows = currentBlock.queryResults[pointsQueryIndex].data;
let columnNames = currentBlock.queryResults[pointsQueryIndex].columns;
let lineDataRows = currentBlock.queryResults[linesQueryIndex].data;
let lineDataColumns = currentBlock.queryResults[linesQueryIndex].columns;
if (dataRows.length && lineDataRows.length) {
createMap();
}
6. DataSource Filter Methods — Use Global Query Filters
Code that iterated through datasource IDs to set filters using zPortal.dataSource.get(dsId).setFilter() and .refresh() should be replaced with the global zPortal.query filter methods, which apply to all active datasources automatically.
Before:
function setFilterForDataSources(dsIds, filterValue) {
dsIds.forEach(dsId => {
const dataSource = zPortal.dataSource.get(dsId);
dataSource.setFilter('column_name', [filterValue]);
dataSource.refresh();
});
}
function clearFiltersForDataSources(dsIds) {
dsIds.forEach(dsId => {
const dataSource = zPortal.dataSource.get(dsId);
dataSource.setFilter('column_name', []);
dataSource.refresh();
});
}
After:
function setFilterForDataSources(filterValue) {
zPortal.query.setFilter('column_name', [filterValue]);
}
function clearFiltersForDataSources() {
zPortal.query.removeFilter('column_name');
}
Key differences:
- No need to maintain a list of datasource IDs or iterate through them
- zPortal.query.setFilter() sets a global filter on all active datasources
- zPortal.query.removeFilter() removes a specific global filter by column name
- zPortal.query.clearFilters() removes all global filters
- No manual .refresh() call needed
7. Data Transformations After Migrating from fetchResults
When migrating code that uses zPortal.dataSource.fetchResults() to currentBlock.queryResults, be aware that existing code may include post-fetch transformations that are no longer needed — or that must be preserved depending on the situation.
A common pattern in pre-1.18.0 code is manually mapping the array-based row data returned by fetchResults into named key-value objects:
Old fetchResults pattern (data comes back as arrays):
const results = await zPortal.dataSource.fetchResults(requestPayload);
const columnNames = results.results[0].columns.map(col => col.name);
const data = results.results[0].data.map(row =>
row.reduce((acc, value, index) => {
acc[columnNames[index]] = value;
return acc;
}, {})
);
In this pattern, fetchResults returns rows as arrays (e.g., [11, 12, "value"]) and columns as objects with a name property. The code then zips them together into key-value objects like { "column_one": 11, "column_two": 12, "column_three": "value" }.
With currentBlock.queryResults, the column-to-row mapping is still needed. Despite what the official docs suggest, queryResults[].data is not an array of key-value objects. It uses the same format as the deprecated currentBlock.data: an object with numeric keys where each value is an array of column values, paired with a separate columns array of column name strings. Use a helper function to transform the data:
function getQueryData(queryIndex) {
const query = currentBlock.queryResults[queryIndex];
if (!query || !query.data) return [];
const { columns, data } = query;
// columns is an array of column name strings
const columnNames = Array.isArray(columns)
? columns.map(col => (typeof col === 'string' ? col : col.name))
: [];
if (columnNames.length === 0) return [];
// data is an object with numeric keys, each value is an array
const numericKeys = Object.keys(data).filter(key => !isNaN(Number(key)));
return numericKeys.map(key => {
const row = data[key];
const obj = {};
columnNames.forEach((col, i) => {
obj[col] = row[i];
});
return obj;
});
}
// Usage:
const data = getQueryData(0); // returns array of key-value objects
Migration guidance:
- Always use a getQueryData() helper to transform the data. queryResults[].data is NOT key-value objects — it requires column mapping.
- If the old code performed additional transformations after the column mapping (e.g., parsing numbers, renaming fields, filtering rows, computing derived values), those transformations still need to happen in the migrated code. Apply them to the output of getQueryData().
- Watch for code that accesses row data by numeric index (e.g., row[0], row[i]). After using getQueryData(), switch to named property access (e.g., row.column_name).
- Note that some columns may contain JSON-typed values (e.g., a background_line column containing a GeoJSON Feature object). After transformation, these are accessible as nested objects on the row (e.g., row.background_line.geometry).
8. Best Practice: 1.18.0 HTML Block Code Pattern
In 1.18.0, block JS runs each time one of its queries loads data. If a block has 3 queries, the script runs 3 times — once as each query's data arrives. Code must be structured around this: grab the loaded callback early, check whether all required queries have loaded, and only build the UI and fire the callback once everything is ready.
The Loaded Callback
currentBlock.getOnLoadedCallback() must be called exactly once per block, and only after the block's UI is fully drawn. The portal waits for this signal to consider the block ready — omitting it stalls the page, and calling it more than once causes undefined behavior.
Because the script runs multiple times (once per query load), you must be careful not to call the callback on an early run when not all queries have arrived yet. The correct pattern:
- Call
currentBlock.getOnLoadedCallback()early to obtain the callback function - Check whether all required queries have loaded their data
- If not all loaded yet — return and do nothing (the script will run again when the next query loads)
- If all loaded — build the UI, then call the callback
Structure
Every HTML block script should follow this order:
const loadedCallback = currentBlock.getOnLoadedCallback()— obtain the callback as one of the first things- QUERY_INDEX — a config object mapping descriptive names to query indices on the block
allQueriesLoadedcheck — verify that every required query has loaded; if not, return early without calling the callback- Entry-point call — call the entry-point function. This goes near the top so a reader immediately sees the control flow.
- getQueryData() — the standard transformation helper (see section 7)
- All rendering functions — accept data as parameters rather than fetching internally
- The entry-point function definition — orchestrates the full rendering pipeline and calls
loadedCallback()when done
Helper: Checking if a Query Has Loaded
A query has loaded when currentBlock.queryResults[index] exists and has a data property. A query having loaded does not mean it returned rows — it may have loaded with an empty result set:
function queryLoaded(index) {
const q = currentBlock.queryResults && currentBlock.queryResults[index];
return q && q.data !== undefined;
}
Example: Map Block (2 queries)
// 1. Obtain loaded callback immediately
const loadedCallback = currentBlock.getOnLoadedCallback();
// 2. Query Index
const QUERY_INDEX = {
manholePoints: 0,
backgroundLines: 1
};
// 3. Check if all queries have loaded — if not, exit and wait for next run
const allLoaded = Object.values(QUERY_INDEX).every(idx => {
const q = currentBlock.queryResults && currentBlock.queryResults[idx];
return q && q.data !== undefined;
});
if (!allLoaded) {
// Not all queries have loaded yet — do nothing, script will re-run
return;
}
// 4. All queries loaded — build the UI
createMap();
// --- Function definitions below ---
// Data Transformation Helper
function getQueryData(queryIndex) {
const query = currentBlock.queryResults[queryIndex];
if (!query || !query.data) { return []; }
const { columns, data } = query;
const columnNames = Array.isArray(columns)
? columns.map(col => (typeof col === 'string' ? col : col.name))
: [];
if (columnNames.length === 0) { return []; }
const numericKeys = Object.keys(data).filter(key => !isNaN(Number(key)));
return numericKeys.map(key => {
const row = data[key];
const obj = {};
columnNames.forEach((col, i) => { obj[col] = row[i]; });
return obj;
});
}
// Rendering functions — receive data, never fetch
function formatPointData(data) {
return data.map(row => ({
type: "Feature",
geometry: { type: "Point", coordinates: [row.longitude, row.latitude] },
properties: row
}));
}
// Entry-point function
function createMap() {
const pointsData = getQueryData(QUERY_INDEX.manholePoints);
const linesData = getQueryData(QUERY_INDEX.backgroundLines);
const formattedPoints = formatPointData(pointsData);
// ... initialize map, add layers, set up event handlers ...
loadedCallback();
}
Example: Report Block (async, 4 queries)
For blocks that load external libraries or do async work, the entry-point function is async and uses try/catch/finally to guarantee the loaded callback fires exactly once after all rendering:
// 1. Obtain loaded callback immediately
const loadedCallback = currentBlock.getOnLoadedCallback();
// 2. Query Index
const QUERY_INDEX = {
conditions: 0,
defects: 1,
repairs: 2,
materials: 3
};
// 3. Check if all queries have loaded
const allLoaded = Object.values(QUERY_INDEX).every(idx => {
const q = currentBlock.queryResults && currentBlock.queryResults[idx];
return q && q.data !== undefined;
});
if (!allLoaded) { return; }
// 4. All queries loaded — build the report
createReport();
// --- Function definitions below ---
// getQueryData() helper — same as map block example above
// Entry-point function
async function createReport() {
try {
await loadAllLibraries();
const conditionsData = getQueryData(QUERY_INDEX.conditions);
const defectsData = getQueryData(QUERY_INDEX.defects);
const repairsData = getQueryData(QUERY_INDEX.repairs);
const materialsData = getQueryData(QUERY_INDEX.materials);
populateCoverPage(conditionsData);
displaySeverityChart(conditionsData);
updateBudgetTable(repairsData);
displayMaterialBreakdownChart(materialsData);
} catch (error) {
console.error('Error in createReport:', error);
} finally {
loadedCallback(); // fires once, after UI is drawn or after error
}
}
Example: UI-Only Block (toggle, single query)
Blocks that don't render data-driven content (e.g., a toggle that shows/hides other blocks). With a single query, the script only runs once so no allLoaded check is needed:
const loadedCallback = currentBlock.getOnLoadedCallback();
document.getElementById('toggleWrapper').style.display = 'flex';
toggleBlocks(false); // set initial state
document.getElementById('toggle').addEventListener('change', (e) => {
toggleBlocks(e.target.checked);
});
loadedCallback();
Key Principles
- The script runs once per query. If the block has 3 queries, the script executes 3 times. The code must check whether all required queries have loaded before doing anything, and return early if they haven't.
- Obtain the callback early, call it late. Call
currentBlock.getOnLoadedCallback()as one of the first things in the script to get the callback function. Call it only once, only after the UI is fully drawn (or after determining there is nothing to draw). Never call it on an early run when queries are still pending. - No polling or listeners. Block JS runs on load of query data. Don't use
setInterval,DOMContentLoaded, orzPortal.block.on('load', ...)to wait for data — it's already there when your code executes. - No internal fetching. Rendering functions receive data as parameters. They never call
zPortal.dataSource.fetchResults()or any other fetch API. All data comes fromgetQueryData()at the top of the entry-point function. - QUERY_INDEX is documentation. It maps human-readable names to positions, making it clear which queries need to be configured in the UI and what each one provides.
General Themes
- Timing changes: Direct calls to
zPortalAPIs (block.getData,dataSource.get) may not return data in time within block code. The consistent new pattern is to usecurrentBlock.queryResults[index]which is guaranteed to be ready when block code executes. - Deprecated DataSource namespace: The entire
zPortal.dataSourcenamespace is deprecated. ReplacezPortal.dataSource.get(),zPortal.dataSource.fetchResults(),zPortal.dataSource.setFilters(), etc. with the equivalentzPortal.querymethods. - Query API: For advanced data fetching (e.g., CSV export), use the new
zPortal.query.getQuery()API with callback-based data loading. TheUserQueryobject provides methods for pagination, filtering, and event handling (onLoad,onError). - Data format: Despite what the official docs suggest,
currentBlock.queryResults[index].datais NOT an array of key-value objects. It uses the same format as the deprecatedcurrentBlock.data— an object with numeric keys where each value is an array of column values. Always use agetQueryData()helper to transform the data. See section 7 for the helper function. - Script re-runs per query: Block JS runs once per query load. If a block has 3 queries, the script executes 3 times. Code must check that all required queries have loaded before building the UI, and return early if any are still pending. Obtain the loaded callback (
currentBlock.getOnLoadedCallback()) early and only call it once, after the UI is fully drawn or after determining there is nothing to draw. - All-loaded guard: Always wrap the main rendering/drawing logic in its own function (e.g.,
createMap(),createChart()) and only call it after verifying that all queries have loaded. This prevents errors on early runs when not all queries are ready. Once all queries have loaded, attempt to build the UI. See section 8 for the full pattern.
Migration Checklist
When migrating an HTML block to 1.18.0, verify the following:
- Replace
zPortal.block.getData(blockid)withcurrentBlock.queryResults[0].data - Replace
zPortal.dataSource.get(datasourceid).columnswithcurrentBlock.queryResults[0].columns - Replace any
zPortal.dataSourcefilter/fetch calls withzPortal.queryequivalents - Replace
zPortal.block.on('load', blockid, ...)patterns for hidden blocks with additional queries on the current block, accessed viacurrentBlock.queryResults[index] - Ensure block queries are created and configured in the UI (Query tab)
- Remove default page size of 50 from queries that need all data
- Use a
getQueryData()helper to normalize queryResults data into key-value objects (data may come as arrays), and preserve any additional business logic transformations - Replace numeric index row access (
row[i]) with named property access (row.column_name) - Structure code following the 1.18.0 pattern: QUERY_INDEX → allLoaded check → entry-point call → getQueryData() → rendering functions → entry-point function definition (see section 8)
- Wrap rendering logic in a named function (e.g.,
createMap()) and call it once all queries have loaded - Remove polling, setTimeout initialization, DOMContentLoaded listeners, and duplicate-init guards — block JS runs when query data loads
- Move data fetching out of rendering functions — they should receive data as parameters, not call fetchDataForSource() internally
- Call
currentBlock.getOnLoadedCallback()early to obtain the callback, then call it exactly once after the UI is fully drawn (usefinallyfor async entry points) - For multi-query blocks, check that all required queries have loaded before building the UI — return early if any are still pending (the script will re-run when the next query loads)
- Verify column name formats — may need to change from quoted/mixed-case to lowercase/underscored
- Replace
currentBlock.data/currentBlock.columnswithcurrentBlock.queryResults[0].data/.columns - Replace
currentBlock.siteConfigwithcurrentBlock.config - Replace
zPortal.layoutwithzPortal.page - Replace
zPortal.siteConfigwithzPortal.config