diff --git a/validate-pages/README.md b/validate-pages/README.md index 1735ada..6905d00 100644 --- a/validate-pages/README.md +++ b/validate-pages/README.md @@ -2,7 +2,7 @@ This project iterates over one or more Port organizations, lists every page in each organization, and validates them via the Port API. It includes two related flows: - **Validate** — only checks pages and reports any validation errors found -- **Fix** — validates pages and, for any page with errors, immediately calls the Port API fix endpoint on it, then reports which fixes were applied +- **Fix** — validates pages and, for any page with errors, applies client-side fixes (including setting `displayMode: "widget"` on `table-entities-explorer` widgets inside `dashboard-widget` containers), calls the Port API fix endpoint, then reports which fixes were applied Each flow can be run as console-only output or as a report (HTML + JSON) generator. @@ -83,8 +83,9 @@ The script will: 1. Authenticate against each organization using its Port API credentials 2. List all pages in the organization (in compact form) 3. Validate each page via the Port API -4. For any page with validation errors, immediately call the Port API fix endpoint on it -5. Report the fixes that were applied to each page +4. Apply client-side fixes on every page (including setting missing `displayMode` on table widgets inside dashboard widgets), then validate each page +5. For any page that is still invalid, call the Port API fix endpoint on it +6. Report the fixes that were applied to each page Validation and fixing are interleaved page-by-page (rather than fixing everything in a second pass after all pages are validated), so partial progress is preserved if the script is interrupted. @@ -101,6 +102,7 @@ For each organization the script prints progress per page, then a summary listin ``` [my-org] 1 page(s) with fixes applied / 42 pages FIXES APPLIED some-broken-page: + - Set displayMode to 'widget' for table-entities-explorer 'relatedTable' (was (missing)) - Removed the id key from links in a links widget NO FIXES APPLIED another-page ``` diff --git a/validate-pages/package.json b/validate-pages/package.json index 76af930..4790a5a 100644 --- a/validate-pages/package.json +++ b/validate-pages/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node pageFixes.test.js", "validate": "node index.js", "validate:report": "node report.js", "fix": "node fix.js", diff --git a/validate-pages/pageFixes.js b/validate-pages/pageFixes.js new file mode 100644 index 0000000..b0009ad --- /dev/null +++ b/validate-pages/pageFixes.js @@ -0,0 +1,92 @@ +const TABLE_ENTITIES_EXPLORER = "table-entities-explorer"; +const DASHBOARD_WIDGET = "dashboard-widget"; +const REQUIRED_DISPLAY_MODE = "widget"; + +/** + * Sets displayMode to "widget" on table-entities-explorer widgets that are + * direct children of dashboard-widget containers. Mirrors the port-api + * validateDashboardWidgetChildDisplayMode guardrail. + * + * @param {Array> | undefined} widgets + * @returns {Array<{name: string, message: string}>} + */ +function normalizeDashboardWidgetChildDisplayMode(widgets) { + const fixes = []; + + function fixDashboardWidgetChildren(dashboardWidget) { + if (!Array.isArray(dashboardWidget.widgets)) { + return; + } + + for (const child of dashboardWidget.widgets) { + if ( + child.type === TABLE_ENTITIES_EXPLORER && + child.displayMode !== REQUIRED_DISPLAY_MODE + ) { + const previous = child.displayMode ?? "(missing)"; + child.displayMode = REQUIRED_DISPLAY_MODE; + const label = child.id || child.title || "unknown"; + fixes.push({ + name: "normalizeDashboardWidgetChildDisplayMode", + message: `Set displayMode to '${REQUIRED_DISPLAY_MODE}' for table-entities-explorer '${label}' (was ${previous})`, + }); + } + + walkWidget(child); + } + } + + function walkWidget(widget) { + if (!widget || typeof widget !== "object") { + return; + } + + if (widget.type === DASHBOARD_WIDGET) { + fixDashboardWidgetChildren(widget); + return; + } + + if (Array.isArray(widget.widgets)) { + for (const nestedWidget of widget.widgets) { + walkWidget(nestedWidget); + } + } + + if (Array.isArray(widget.groups)) { + for (const group of widget.groups) { + if (Array.isArray(group.widgets)) { + for (const nestedWidget of group.widgets) { + walkWidget(nestedWidget); + } + } + } + } + } + + if (Array.isArray(widgets)) { + for (const widget of widgets) { + walkWidget(widget); + } + } + + return fixes; +} + +/** + * Applies all client-side page fixes and returns the fix messages produced. + * Mutates the page object in place. + * + * @param {Record} page + * @returns {Array<{name: string, message: string}>} + */ +function applyClientSidePageFixes(page) { + return normalizeDashboardWidgetChildDisplayMode(page.widgets); +} + +module.exports = { + TABLE_ENTITIES_EXPLORER, + DASHBOARD_WIDGET, + REQUIRED_DISPLAY_MODE, + normalizeDashboardWidgetChildDisplayMode, + applyClientSidePageFixes, +}; diff --git a/validate-pages/pageFixes.test.js b/validate-pages/pageFixes.test.js new file mode 100644 index 0000000..38b44a7 --- /dev/null +++ b/validate-pages/pageFixes.test.js @@ -0,0 +1,55 @@ +const assert = require("assert"); +const { + normalizeDashboardWidgetChildDisplayMode, +} = require("./pageFixes"); + +function runTests() { + const nestedTable = { + id: "relatedTable", + type: "table-entities-explorer", + title: "Related Entities", + }; + + const pageWidgets = [ + { + id: "entityPageGrouper", + type: "grouper", + groups: [ + { + title: "Overview", + widgets: [ + { + id: "overviewDashboard", + type: "dashboard-widget", + widgets: [nestedTable], + }, + ], + }, + ], + }, + ]; + + const fixes = normalizeDashboardWidgetChildDisplayMode(pageWidgets); + + assert.strictEqual(fixes.length, 1); + assert.strictEqual(nestedTable.displayMode, "widget"); + assert.match(fixes[0].message, /relatedTable/); + + const unchanged = normalizeDashboardWidgetChildDisplayMode(pageWidgets); + assert.strictEqual(unchanged.length, 0); + + const catalogWidgets = [ + { + id: "catalogTable", + type: "table-entities-explorer", + displayMode: "tabs", + }, + ]; + const catalogFixes = normalizeDashboardWidgetChildDisplayMode(catalogWidgets); + assert.strictEqual(catalogFixes.length, 0); + assert.strictEqual(catalogWidgets[0].displayMode, "tabs"); + + console.log("pageFixes.test.js: all tests passed"); +} + +runTests(); diff --git a/validate-pages/portClient.js b/validate-pages/portClient.js index cdccc88..860f991 100644 --- a/validate-pages/portClient.js +++ b/validate-pages/portClient.js @@ -1,4 +1,5 @@ const axios = require("axios"); +const { applyClientSidePageFixes } = require("./pageFixes"); /** * Mirrors Python's KeyError so we can report a missing env/config key the same @@ -117,16 +118,44 @@ async function collectFindings(apiUrl, org) { return { name, totalPages: pages.length, findings, failedPages }; } +async function getPage(apiUrl, headers, identifier) { + const res = await axios.get(`${apiUrl}/v1/pages/${identifier}`, { headers }); + return res.data.page; +} + +async function putPage(apiUrl, headers, identifier, page) { + await axios.put(`${apiUrl}/v1/pages/${identifier}`, page, { headers }); +} + +/** + * Applies client-side page fixes (e.g. missing displayMode on table widgets + * inside dashboard widgets) and persists the page when changes are made. + * + * @param {string} apiUrl + * @param {Record} headers + * @param {string} identifier + * @returns {Promise>} + */ +async function applyAndSaveClientSidePageFixes(apiUrl, headers, identifier) { + const page = await getPage(apiUrl, headers, identifier); + const fixedErrors = applyClientSidePageFixes(page); + if (fixedErrors.length === 0) { + return []; + } + + await putPage(apiUrl, headers, identifier, page); + return fixedErrors; +} + /** - * Calls the fix endpoint for a single page and returns the list of errors it - * fixed. + * Calls the Port API fix endpoint for a single page. * * @param {string} apiUrl * @param {Record} headers * @param {string} identifier * @returns {Promise<{ok: boolean, fixedErrors: Array<{name: string, message: string}>}>} */ -async function fixPage(apiUrl, headers, identifier) { +async function callApiPageFix(apiUrl, headers, identifier) { const res = await axios.post( `${apiUrl}/v1/pages/${identifier}/fix`, {}, @@ -135,6 +164,29 @@ async function fixPage(apiUrl, headers, identifier) { return res.data; } +/** + * Applies client-side fixes and then calls the Port API fix endpoint. + * + * @param {string} apiUrl + * @param {Record} headers + * @param {string} identifier + * @returns {Promise<{ok: boolean, fixedErrors: Array<{name: string, message: string}>}>} + */ +async function fixPage(apiUrl, headers, identifier) { + const clientFixedErrors = await applyAndSaveClientSidePageFixes( + apiUrl, + headers, + identifier + ); + const apiResult = await callApiPageFix(apiUrl, headers, identifier); + const apiFixedErrors = apiResult.fixedErrors || []; + + return { + ...apiResult, + fixedErrors: [...clientFixedErrors, ...apiFixedErrors], + }; +} + /** * Authenticates against a single org, lists its pages, validates each one, * and for any page with validation errors, immediately calls the fix endpoint @@ -167,58 +219,109 @@ async function collectFixes(apiUrl, org) { (page) => !SKIP_PAGE_IDENTIFIERS.has(page.identifier) ); - console.log(`Found ${pages.length} pages to validate`); + console.log(`Found ${pages.length} pages to validate and fix`); const fixes = []; const unexpectedFailures = []; for (let i = 0; i < pages.length; i++) { const page = pages[i]; const identifier = page.identifier; - console.log(` [${i + 1}/${pages.length}] Validating ${identifier}`); - let errorsBefore; + console.log(` [${i + 1}/${pages.length}] Checking ${identifier}`); + let clientFixedErrors = []; + try { + clientFixedErrors = await applyAndSaveClientSidePageFixes( + apiUrl, + headers, + identifier + ); + } catch (error) { + const reason = error.response + ? `HTTP ${error.response.status}` + : error.message; + console.warn( + ` WARNING: failed to apply client-side fixes to ${identifier}: ${reason}` + ); + unexpectedFailures.push({ + identifier, + title: page.title, + reason, + stage: "fix", + }); + continue; + } + + let errorsBefore = []; + let pageIsValid = true; try { const validateRes = await axios.get( `${apiUrl}/v1/pages/${identifier}/validate`, { headers } ); const result = validateRes.data; - if (result.valid ?? true) { - continue; + pageIsValid = result.valid ?? true; + if (!pageIsValid) { + errorsBefore = result.errors || []; } - errorsBefore = result.errors || []; } catch (error) { const reason = error.response ? `HTTP ${error.response.status}` : error.message; console.warn(` WARNING: failed to validate ${identifier}: ${reason}`); - unexpectedFailures.push({ identifier, title: page.title, reason, stage: "validate" }); + unexpectedFailures.push({ + identifier, + title: page.title, + reason, + stage: "validate", + }); + continue; + } + + if (pageIsValid) { + if (clientFixedErrors.length > 0) { + fixes.push({ + identifier, + title: page.title, + errorsBefore: [], + fixedErrors: clientFixedErrors, + fixesApplied: true, + }); + } continue; } console.log(` [${i + 1}/${pages.length}] Fixing ${identifier}`); + let apiFixedErrors = []; try { - const fixResult = await fixPage(apiUrl, headers, identifier); - const fixedErrors = fixResult.fixedErrors || []; - fixes.push({ - identifier, - title: page.title, - errorsBefore, - fixedErrors, - // `fixedErrors` is a list of fix *operations*, not a 1:1 list of the - // original validation errors — a single operation (e.g. "remove id - // from links") can resolve multiple entries in `errorsBefore` at - // once. So we can only say whether at least one fix was applied - // (`fixesApplied`), not how many of the original errors were - // resolved or how many remain; that requires re-validating the page. - fixesApplied: fixedErrors.length > 0, - }); + const fixResult = await callApiPageFix(apiUrl, headers, identifier); + apiFixedErrors = fixResult.fixedErrors || []; } catch (error) { const reason = error.response ? `HTTP ${error.response.status}` : error.message; console.warn(` WARNING: failed to fix ${identifier}: ${reason}`); - unexpectedFailures.push({ identifier, title: page.title, reason, stage: "fix" }); + unexpectedFailures.push({ + identifier, + title: page.title, + reason, + stage: "fix", + }); + continue; } + + const fixedErrors = [...clientFixedErrors, ...apiFixedErrors]; + fixes.push({ + identifier, + title: page.title, + errorsBefore, + fixedErrors, + // `fixedErrors` is a list of fix *operations*, not a 1:1 list of the + // original validation errors — a single operation (e.g. "remove id + // from links") can resolve multiple entries in `errorsBefore` at + // once. So we can only say whether at least one fix was applied + // (`fixesApplied`), not how many of the original errors were + // resolved or how many remain; that requires re-validating the page. + fixesApplied: fixedErrors.length > 0, + }); } const pagesWithFixesApplied = fixes.filter((fix) => fix.fixesApplied).length; @@ -235,6 +338,10 @@ module.exports = { getApiUrl, parseOrgs, getToken, + getPage, + putPage, + applyAndSaveClientSidePageFixes, + callApiPageFix, collectFindings, fixPage, collectFixes,