From 64810e4ea97dd74f586b45b1a97d42c053f9a0e1 Mon Sep 17 00:00:00 2001 From: Adam Hooper Date: Fri, 17 Jul 2026 13:21:05 -0400 Subject: [PATCH 1/6] widgets: support nested oneOf --- assets/scripts/build-api-pages.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/assets/scripts/build-api-pages.js b/assets/scripts/build-api-pages.js index c5b218fcfdd..36baff0bed4 100644 --- a/assets/scripts/build-api-pages.js +++ b/assets/scripts/build-api-pages.js @@ -1103,12 +1103,17 @@ const processSpecs = (specs) => { if(deref.components.schemas && deref.components.schemas.WidgetDefinition && deref.components.schemas.WidgetDefinition.oneOf) { const jsonData = {}; const pageDir = `./content/en/api/${version}/dashboards/`; - deref.components.schemas.WidgetDefinition.oneOf.forEach((widget) => { + const addWidget = (widget) => { + if (widget.oneOf) { + widget.oneOf.forEach(addWidget); + return; + } const requestJson = filterExampleJson("request", widget); const requestCurlJson = filterExampleJson("curl", widget); const html = schemaTable("request", widget); jsonData[widget.properties.type.default] = {"json_curl": requestCurlJson, "json": requestJson, "html": html}; - }); + }; + deref.components.schemas.WidgetDefinition.oneOf.forEach(addWidget); fs.writeFileSync(`${pageDir}widgets.json`, safeJsonStringify(jsonData, null, 2), 'utf-8'); } From ec5de224436de80320d365be84ee8507798d504d Mon Sep 17 00:00:00 2001 From: Adam Hooper Date: Fri, 17 Jul 2026 13:33:18 -0400 Subject: [PATCH 2/6] refactor: extract getOneOfChildData() --- assets/scripts/build-api-pages.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/assets/scripts/build-api-pages.js b/assets/scripts/build-api-pages.js index 36baff0bed4..3b880f948aa 100644 --- a/assets/scripts/build-api-pages.js +++ b/assets/scripts/build-api-pages.js @@ -838,6 +838,14 @@ const descColumn = (key, value) => { return `
${descHtml}${def}
`.trim(); }; +/** + * @param {array} items - oneOf items + * @returns {object} object keyed by item name + */ +const getOneOfChildData = (items) => Object.fromEntries( + items.map((item, i) => [`Object ${i}`, item]) +); + /** * Takes a application/json schema for request or response and outputs a table @@ -881,11 +889,7 @@ const rowRecursive = (tableType, data, isNested, requiredFields=[], level = 0, p } // for items -> oneOf if (value.items.oneOf && value.items.oneOf instanceof Array && value.items.oneOf.length < oneOfLimit) { - childData = value.items.oneOf - .map((obj, indx) => { - return {[`Option ${indx + 1}`]: value.items.oneOf[indx]} - }) - .reduce((obj, item) => ({...obj, ...item}), {}); + childData = getOneOfChildData(value.items.oneOf); } } else if(typeof value.items === 'string') { if(value.items === '[Circular]') { @@ -904,11 +908,7 @@ const rowRecursive = (tableType, data, isNested, requiredFields=[], level = 0, p } else if (typeof value === 'object' && "oneOf" in value) { // for properties -> oneOf if(value.oneOf instanceof Array && value.oneOf.length < oneOfLimit) { - childData = value.oneOf - .map((obj, indx) => { - return {[`Option ${indx + 1}`]: value.oneOf[indx]} - }) - .reduce((obj, item) => ({...obj, ...item}), {}); + childData = getOneOfChildData(value.oneOf); } } // for widgets From 89a98539f33a2f3766e422445f32bccc612813be Mon Sep 17 00:00:00 2001 From: Adam Hooper Date: Fri, 17 Jul 2026 14:46:49 -0400 Subject: [PATCH 3/6] Instead of "Object 37", label widget "" --- assets/scripts/build-api-pages.js | 103 ++++++++++++++++++- assets/scripts/tests/build-api-pages.test.js | 23 +++++ 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/assets/scripts/build-api-pages.js b/assets/scripts/build-api-pages.js index 3b880f948aa..99464598302 100644 --- a/assets/scripts/build-api-pages.js +++ b/assets/scripts/build-api-pages.js @@ -838,13 +838,110 @@ const descColumn = (key, value) => { return `
${descHtml}${def}
`.trim(); }; +/** + * Shim for Map.groupBy(), which is only available in Node 21+ (this repo + * targets Node 20). + * + * @param {Iterable} items - items to group + * @param {function} keyFn - maps an item to its group key + * @returns {Map} map of key to array of items + */ +const groupBy = (items, keyFn) => { + const map = new Map(); + items.forEach((item, i) => { + const key = keyFn(item, i); + const group = map.get(key); + if (group) { + group.push(item); + } else { + map.set(key, [item]); + } + }); + return map; +}; + +/** + * Returns "" if `item` is an object schema with a const `type` field + * @param {object} schema + * @returns "" if the schema is a const or 1-value string enum + */ +const getMaybeConstTypeName = (item, discriminant) => { + const type = item.properties?.[discriminant]; + if ( + type + && type.type === 'string' + && type.enum + && type.enum.length === 1 + ) { + return `<${discriminant}=${type.enum[0]}>`; + } + return undefined; +} + +/** + * Auto-detects the best discriminant property across a set of oneOf branches. + * + * A property is a candidate discriminant when every branch either omits it or + * defines it as a string enum. + * + * Preference order: + * 1. The first candidate that covers every branch (all-defined + unique). + * 2. Otherwise, the candidate covering the most branches, provided it covers + * all-but-one branch or at least 75% of branches. + * + * @param {array} items - oneOf items + * @returns {string|undefined} the discriminant property name, or undefined + */ +const getBestDiscriminant = (items) => { + const candidates = Array.from(new Set( + items.flatMap((item) => Object.keys(item.properties ?? {})) + )); + const enumCandidates = candidates.filter((d) => items.some((item) => { + const prop = item.properties?.[d]; + return prop && prop.type === 'string' && prop.enum?.length === 1; + })); + const onlyEnumCandidates = enumCandidates.filter((d) => !items.some((item) => { + const prop = item.properties?.[d]; + return prop && (prop.type !== 'string' || !prop.enum); + })); + + const countedCandidates = onlyEnumCandidates.map((d) => { + const byName = groupBy(items, (item) => getMaybeConstTypeName(item, d)); + const nUnique = [...byName].reduce( + (acc, [value, arr]) => acc + ((value && arr.length === 1) ? 1 : 0), + 0, + ); + return { name: d, nUnique }; + }); + countedCandidates.sort((a, b) => b.nUnique - a.nUnique); + + const bestCandidate = countedCandidates[0]; + if (!bestCandidate) { + return undefined; + } + + const { name, nUnique } = bestCandidate; + if (nUnique >= items.length - 1 || (nUnique / items.length) >= 0.75) { + return name; + } + + return undefined; +} + /** * @param {array} items - oneOf items * @returns {object} object keyed by item name */ -const getOneOfChildData = (items) => Object.fromEntries( - items.map((item, i) => [`Object ${i}`, item]) -); +const getOneOfChildData = (items) => { + const discriminant = getBestDiscriminant(items); + const namedEntries = items.map((item) => [discriminant ? getMaybeConstTypeName(item, discriminant) : undefined, item]); + const byName = groupBy(namedEntries, ([name]) => name); + const entries = namedEntries.map(([name, item], i) => + // if name is unique, use it; otherwise, use "Object 3" + [name && byName.get(name)?.length === 1 ? name : `Object ${i}`, item] + ); + return Object.fromEntries(entries); +} /** diff --git a/assets/scripts/tests/build-api-pages.test.js b/assets/scripts/tests/build-api-pages.test.js index 6e9dc7e25e7..11427e7b6a8 100644 --- a/assets/scripts/tests/build-api-pages.test.js +++ b/assets/scripts/tests/build-api-pages.test.js @@ -1,5 +1,7 @@ import * as bp from '../build-api-pages'; import fs from 'fs'; +import yaml from 'js-yaml'; +import $RefParser from '@apidevtools/json-schema-ref-parser'; describe(`updateMenu`, () => { @@ -2810,3 +2812,24 @@ describe(`schemaTable`, () => { }); }); + +describe(`getBestDiscriminant`, () => { + + let schemas; + beforeAll(async () => { + const spec = yaml.safeLoad(fs.readFileSync('./data/api/v1/full_spec.yaml', 'utf8')); + const deref = await $RefParser.dereference(spec, { resolve: { external: false } }); + schemas = deref.components.schemas; + }); + + it('discriminates WidgetDefinition on `type`', () => { + expect(bp.getBestDiscriminant(schemas.WidgetDefinition.oneOf)).toEqual('type'); + }); + + it('discriminates timeseries requests[].queries[] on `data_source`', () => { + const queries = schemas.TimeseriesWidgetRequest.properties.queries.items; + expect(queries).toBe(schemas.FormulaAndFunctionQueryDefinition); + expect(bp.getBestDiscriminant(queries.oneOf)).toEqual('data_source'); + }); + +}); From 08c566844f3264a1df15ebbe41e57e449ef66531 Mon Sep 17 00:00:00 2001 From: Adam Hooper Date: Fri, 17 Jul 2026 15:15:49 -0400 Subject: [PATCH 4/6] Export getBestDiscriminant and fix Object label off-by-one --- assets/scripts/build-api-pages.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/scripts/build-api-pages.js b/assets/scripts/build-api-pages.js index 99464598302..90eb6fbfd41 100644 --- a/assets/scripts/build-api-pages.js +++ b/assets/scripts/build-api-pages.js @@ -938,7 +938,7 @@ const getOneOfChildData = (items) => { const byName = groupBy(namedEntries, ([name]) => name); const entries = namedEntries.map(([name, item], i) => // if name is unique, use it; otherwise, use "Object 3" - [name && byName.get(name)?.length === 1 ? name : `Object ${i}`, item] + [name && byName.get(name)?.length === 1 ? name : `Object ${i + 1}`, item] ); return Object.fromEntries(entries); } @@ -1258,6 +1258,7 @@ const init = () => { module.exports = { init, + getBestDiscriminant, isTagMatch, isReadOnlyRow, descColumn, From ee4041c390db33563fa9d1b781c4e8b45d5cea35 Mon Sep 17 00:00:00 2001 From: Adam Hooper Date: Fri, 17 Jul 2026 19:33:25 +0000 Subject: [PATCH 5/6] Better jsdoc Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- assets/scripts/build-api-pages.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/assets/scripts/build-api-pages.js b/assets/scripts/build-api-pages.js index 90eb6fbfd41..8ccea877c7a 100644 --- a/assets/scripts/build-api-pages.js +++ b/assets/scripts/build-api-pages.js @@ -863,7 +863,11 @@ const groupBy = (items, keyFn) => { /** * Returns "" if `item` is an object schema with a const `type` field * @param {object} schema - * @returns "" if the schema is a const or 1-value string enum + * Returns `` when `item.properties[discriminant]` is a + * single-value string enum. + * @param {object} item - schema object + * @param {string} discriminant - property name to inspect + * @returns {string|undefined} formatted label, or undefined if not a const */ const getMaybeConstTypeName = (item, discriminant) => { const type = item.properties?.[discriminant]; From a0f322d23449f2ef59ad91e398082e84949cdc5b Mon Sep 17 00:00:00 2001 From: Adam Hooper Date: Fri, 17 Jul 2026 15:36:16 -0400 Subject: [PATCH 6/6] Add semicolons --- assets/scripts/build-api-pages.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/scripts/build-api-pages.js b/assets/scripts/build-api-pages.js index 8ccea877c7a..e9d3a308dfe 100644 --- a/assets/scripts/build-api-pages.js +++ b/assets/scripts/build-api-pages.js @@ -880,7 +880,7 @@ const getMaybeConstTypeName = (item, discriminant) => { return `<${discriminant}=${type.enum[0]}>`; } return undefined; -} +}; /** * Auto-detects the best discriminant property across a set of oneOf branches. @@ -930,7 +930,7 @@ const getBestDiscriminant = (items) => { } return undefined; -} +}; /** * @param {array} items - oneOf items @@ -945,7 +945,7 @@ const getOneOfChildData = (items) => { [name && byName.get(name)?.length === 1 ? name : `Object ${i + 1}`, item] ); return Object.fromEntries(entries); -} +}; /**