Implemented the Plan Detail view to display consumption overages or forfeitures
This commit is contained in:
21
README.md
21
README.md
@@ -31,13 +31,30 @@ Do not edit files under `dist` directly. Both targets use Manifest V3, with a
|
|||||||
Chromium service worker and Firefox background scripts. The build composes classic
|
Chromium service worker and Firefox background scripts. The build composes classic
|
||||||
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
|
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
|
||||||
context. It serializes `src/content/styles/redwood.css` followed by the scoped
|
context. It serializes `src/content/styles/redwood.css` followed by the scoped
|
||||||
`src/features/debug/styles.css`, `src/features/opportunities/styles.css` and
|
`src/features/debug/styles.css`, `src/features/opportunities/styles.css`,
|
||||||
`src/features/install-base/styles.css` and `src/features/service-requests/styles.css` at
|
`src/features/install-base/styles.css`, `src/features/service-requests/styles.css`
|
||||||
|
and `src/features/consumption/plan-detail.css` at
|
||||||
build time. There is no
|
build time. There is no
|
||||||
runtime module loader, browser-side eval, ES-module requirement or new dependency.
|
runtime module loader, browser-side eval, ES-module requirement or new dependency.
|
||||||
|
|
||||||
## Tests and Maintenance
|
## Tests and Maintenance
|
||||||
|
|
||||||
|
Cloud Consumption loads `requestConsumptionPlanDetail` for the active plan after
|
||||||
|
account plans arrive, independently of the chart. Selecting another plan loads
|
||||||
|
its `subPlanNum` on demand. The GET uses `active=true` and the raw OCICONS-TS
|
||||||
|
`uiaasHeader.value` in `G2m-Authorization`, through the existing consumer session.
|
||||||
|
Projection badges align with the chart container's left edge, while the
|
||||||
|
Quarterly/Monthly/Weekly/Daily controls stay on the right of the same header row:
|
||||||
|
red Forfeit, blue Overage.
|
||||||
|
Hover or keyboard focus reveals an interactive tooltip; clicking pins it for
|
||||||
|
access to the Order number links. Escape, Close, outside clicks, tab changes and
|
||||||
|
modal closure dismiss it. Each root line retains its nested `clines` details.
|
||||||
|
Only HTTPS order links on `accountdashboard.oracle.com` become clickable.
|
||||||
|
Amounts use the requested non-LC fields, formatted without inferring a local
|
||||||
|
currency. Details are kept only in the current modal, with in-flight deduplication
|
||||||
|
and local loading/empty/error states. Failed details do not block the chart.
|
||||||
|
The existing Consumption debug category includes this request.
|
||||||
|
|
||||||
The Service Requests action sits immediately after Installed Base below Customer.
|
The Service Requests action sits immediately after Installed Base below Customer.
|
||||||
Its dialog opens with skeleton rows and lists open SRs for the selected customer.
|
Its dialog opens with skeleton rows and lists open SRs for the selected customer.
|
||||||
The background first calls `requestAccountSummary` with ACCP-TS, then uses its
|
The background first calls `requestAccountSummary` with ACCP-TS, then uses its
|
||||||
|
|||||||
42
dist/chromium/background.js
vendored
42
dist/chromium/background.js
vendored
@@ -13,7 +13,8 @@ function createMessageTypes() {
|
|||||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +624,32 @@ function createSpaConsumption({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { requestConsumptionTrend };
|
async function requestConsumptionPlanDetail(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
|
||||||
|
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
|
||||||
|
if (!subPlanNum) throw new Error("Sub plan number is required for plan details.");
|
||||||
|
if (!authorization.value) throw new Error("OCICONS-TS authorization is unavailable for plan details.");
|
||||||
|
const url = `https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/plandetail/${encodeURIComponent(subPlanNum)}?active=true`;
|
||||||
|
const items = await fetchJson(url, {
|
||||||
|
method: "GET", credentials: "include", cache: "no-store",
|
||||||
|
headers: { Accept: "application/json", "G2m-Authorization": authorization.value }
|
||||||
|
}, "requestConsumptionPlanDetail", debugRequests);
|
||||||
|
const validLines = (lines, depth = 0) => Array.isArray(lines) && depth <= 20 && lines.every(line =>
|
||||||
|
line && typeof line === "object" && !Array.isArray(line)
|
||||||
|
&& (line.clines === undefined || line.clines === null || validLines(line.clines, depth + 1)));
|
||||||
|
if (!validLines(items)) throw new Error("requestConsumptionPlanDetail returned invalid plan details.");
|
||||||
|
return { ok: true, subPlanNum, items, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
const entry = debugRequests[debugRequests.length - 1];
|
||||||
|
if (entry && entry.status === "success") { entry.status = "failed"; entry.error = error.message; }
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestConsumptionTrend, requestConsumptionPlanDetail };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/response-shapes.js
|
// Source: src/services/spa/response-shapes.js
|
||||||
@@ -1580,6 +1606,7 @@ function registerBackgroundMessages({
|
|||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
|
requestConsumptionPlanDetail,
|
||||||
requestOpptyProducts,
|
requestOpptyProducts,
|
||||||
runtimeApi
|
runtimeApi
|
||||||
}) {
|
}) {
|
||||||
@@ -1687,6 +1714,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestConsumptionPlanDetail) {
|
||||||
|
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load consumption plan details.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestConsumptionTrend) {
|
if (message.type === messageTypes.requestConsumptionTrend) {
|
||||||
requestConsumptionTrend(message)
|
requestConsumptionTrend(message)
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
@@ -1821,10 +1856,11 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
|
const requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
|
||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
function start() {
|
function start() {
|
||||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestOpptyProducts, runtimeApi });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
|
|||||||
261
dist/chromium/content.js
vendored
261
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
42
dist/firefox/background.js
vendored
42
dist/firefox/background.js
vendored
@@ -13,7 +13,8 @@ function createMessageTypes() {
|
|||||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +624,32 @@ function createSpaConsumption({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { requestConsumptionTrend };
|
async function requestConsumptionPlanDetail(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
|
||||||
|
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
|
||||||
|
if (!subPlanNum) throw new Error("Sub plan number is required for plan details.");
|
||||||
|
if (!authorization.value) throw new Error("OCICONS-TS authorization is unavailable for plan details.");
|
||||||
|
const url = `https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/plandetail/${encodeURIComponent(subPlanNum)}?active=true`;
|
||||||
|
const items = await fetchJson(url, {
|
||||||
|
method: "GET", credentials: "include", cache: "no-store",
|
||||||
|
headers: { Accept: "application/json", "G2m-Authorization": authorization.value }
|
||||||
|
}, "requestConsumptionPlanDetail", debugRequests);
|
||||||
|
const validLines = (lines, depth = 0) => Array.isArray(lines) && depth <= 20 && lines.every(line =>
|
||||||
|
line && typeof line === "object" && !Array.isArray(line)
|
||||||
|
&& (line.clines === undefined || line.clines === null || validLines(line.clines, depth + 1)));
|
||||||
|
if (!validLines(items)) throw new Error("requestConsumptionPlanDetail returned invalid plan details.");
|
||||||
|
return { ok: true, subPlanNum, items, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
const entry = debugRequests[debugRequests.length - 1];
|
||||||
|
if (entry && entry.status === "success") { entry.status = "failed"; entry.error = error.message; }
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestConsumptionTrend, requestConsumptionPlanDetail };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/response-shapes.js
|
// Source: src/services/spa/response-shapes.js
|
||||||
@@ -1580,6 +1606,7 @@ function registerBackgroundMessages({
|
|||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
|
requestConsumptionPlanDetail,
|
||||||
requestOpptyProducts,
|
requestOpptyProducts,
|
||||||
runtimeApi
|
runtimeApi
|
||||||
}) {
|
}) {
|
||||||
@@ -1687,6 +1714,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestConsumptionPlanDetail) {
|
||||||
|
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load consumption plan details.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestConsumptionTrend) {
|
if (message.type === messageTypes.requestConsumptionTrend) {
|
||||||
requestConsumptionTrend(message)
|
requestConsumptionTrend(message)
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
@@ -1821,10 +1856,11 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
|
const requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
|
||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
function start() {
|
function start() {
|
||||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestOpptyProducts, runtimeApi });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
|
|||||||
261
dist/firefox/content.js
vendored
261
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -32,6 +32,7 @@ different from extension storage and is not a safe place for credentials.
|
|||||||
| Content | Opportunity query and presentation | Filter sets, sorting, search, request versions, column formatters, dashboard animation |
|
| Content | Opportunity query and presentation | Filter sets, sorting, search, request versions, column formatters, dashboard animation |
|
||||||
| Content | Products UI | Current request ID, product map, expanded rows, progress, last update |
|
| Content | Products UI | Current request ID, product map, expanded rows, progress, last update |
|
||||||
| Content | Consumption UI | Plan state, per-plan/period trend cache, chart navigation and selected series |
|
| Content | Consumption UI | Plan state, per-plan/period trend cache, chart navigation and selected series |
|
||||||
|
| Content | Plan projection details | Per-panel WeakMap, shared in-flight requests, nested tooltip DOM, temporary pointer/resize listeners cleaned up on dismiss/tab change/modal close |
|
||||||
| Content | Service Requests UI | Customer-specific in-flight queries, skeleton/table modal, stale-response guards; no persistent cache |
|
| Content | Service Requests UI | Customer-specific in-flight queries, skeleton/table modal, stale-response guards; no persistent cache |
|
||||||
| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion |
|
| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion |
|
||||||
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag |
|
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag |
|
||||||
@@ -56,7 +57,7 @@ Production tokens are held in memory, not deliberately persisted by the extensio
|
|||||||
| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity POST and product GET Bearer | Content promise/cache; no expiry-based refresh equivalent to Sales Common |
|
| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity POST and product GET Bearer | Content promise/cache; no expiry-based refresh equivalent to Sales Common |
|
||||||
| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 |
|
| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 |
|
||||||
| ACCP-TS | SPA consumer service `uiaasHeader.value` | Account summary authorization | Retrieved with the consumer service response; expiry recorded, not automatically acted on |
|
| ACCP-TS | SPA consumer service `uiaasHeader.value` | Account summary authorization | Retrieved with the consumer service response; expiry recorded, not automatically acted on |
|
||||||
| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans and consumption trend authorization | Same service response, distinct credential from ACCP-TS |
|
| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans, consumption trend and plan detail authorization | Same service response, distinct credential from ACCP-TS |
|
||||||
| SPA-ORANGE | SPA consumer service `uiaasHeader.value` | Customer service requests, fixed `custsr-auth` header with raw value, no Bearer prefix; ignores `uiaasHeader.label` | Optional at consumer acquisition; required only when opening Service Requests; existing in-memory session rules |
|
| SPA-ORANGE | SPA consumer service `uiaasHeader.value` | Customer service requests, fixed `custsr-auth` header with raw value, no Bearer prefix; ignores `uiaasHeader.label` | Optional at consumer acquisition; required only when opening Service Requests; existing in-memory session rules |
|
||||||
|
|
||||||
Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use
|
Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use
|
||||||
@@ -73,6 +74,7 @@ operation-specific headers rather than unifying all calls under a default token.
|
|||||||
| requestCustomerServiceRequest | GET `provider-proxy/g2m-custsr-provider/service/oscSr/{accountSummaryId}` | SPA-ORANGE; summary `id`, not `registryId`; encoded filter `{hierarchy:"single",status:"open"}`, creationDate descending, pages of 40 using totalResults/count/offset |
|
| requestCustomerServiceRequest | GET `provider-proxy/g2m-custsr-provider/service/oscSr/{accountSummaryId}` | SPA-ORANGE; summary `id`, not `registryId`; encoded filter `{hierarchy:"single",status:"open"}`, creationDate descending, pages of 40 using totalResults/count/offset |
|
||||||
| requestAccountPlans | POST `provider-proxy/g2m-consumption/service/v1/account/accountPlans/{id}` | OCICONS-TS; original hierarchy/products/language/planStatus payload |
|
| requestAccountPlans | POST `provider-proxy/g2m-consumption/service/v1/account/accountPlans/{id}` | OCICONS-TS; original hierarchy/products/language/planStatus payload |
|
||||||
| requestConsumptionService{Period} | GET `provider-proxy/g2m-consumption/service/v1/plan/trend/{subPlanNum}?quarter=All&timePeriod={period}` | G2m-Authorization; quarterly/monthly/weekly/daily; weekly default |
|
| requestConsumptionService{Period} | GET `provider-proxy/g2m-consumption/service/v1/plan/trend/{subPlanNum}?quarter=All&timePeriod={period}` | G2m-Authorization; quarterly/monthly/weekly/daily; weekly default |
|
||||||
|
| requestConsumptionPlanDetail | GET `provider-proxy/g2m-consumption/service/v1/plan/plandetail/{subPlanNum}?active=true` | Raw OCICONS-TS in G2m-Authorization; array of lines with optional nested clines; no retry or persisted cache |
|
||||||
|
|
||||||
SPA parsing removes BOM and the existing anti-JSON-hijacking prefixes before
|
SPA parsing removes BOM and the existing anti-JSON-hijacking prefixes before
|
||||||
JSON.parse. Content uses response.json directly. Do not silently unify these behaviors.
|
JSON.parse. Content uses response.json directly. Do not silently unify these behaviors.
|
||||||
@@ -97,6 +99,7 @@ was silently changed during extraction.
|
|||||||
| requestCustomerServiceRequest | partyNumber, authorizations.accountSummary/customerServiceRequests | ok, items (consolidated response.objects), debugRequests; error envelope on failure |
|
| requestCustomerServiceRequest | partyNumber, authorizations.accountSummary/customerServiceRequests | ok, items (consolidated response.objects), debugRequests; error envelope on failure |
|
||||||
| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests |
|
| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests |
|
||||||
| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests |
|
| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests |
|
||||||
|
| requestConsumptionPlanDetail | subPlanNum, authorization | ok, subPlanNum, items (including clines), debugRequests; error envelope on failure |
|
||||||
|
|
||||||
SPA failures return `{ok:false,error,debugRequests}`. Progress states include
|
SPA failures return `{ok:false,error,debugRequests}`. Progress states include
|
||||||
`running`, `complete` and `complete-with-errors`; content additionally tracks idle
|
`running`, `complete` and `complete-with-errors`; content additionally tracks idle
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function composeScript(target, { bootstrap = true } = {}) {
|
|||||||
return `// Source: ${file}\n${source}`;
|
return `// Source: ${file}\n${source}`;
|
||||||
});
|
});
|
||||||
if (target === "content") {
|
if (target === "content") {
|
||||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css"]
|
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css"]
|
||||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||||
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ module.exports = {
|
|||||||
"src/features/service-requests/action.js",
|
"src/features/service-requests/action.js",
|
||||||
"src/features/service-requests/view.js",
|
"src/features/service-requests/view.js",
|
||||||
"src/features/consumption/plans-view.js",
|
"src/features/consumption/plans-view.js",
|
||||||
|
"src/features/consumption/plan-detail-controller.js",
|
||||||
|
"src/features/consumption/plan-detail-view.js",
|
||||||
"src/features/consumption/trend-controller.js",
|
"src/features/consumption/trend-controller.js",
|
||||||
"src/features/consumption/chart.js",
|
"src/features/consumption/chart.js",
|
||||||
"src/features/consumption/model.js",
|
"src/features/consumption/model.js",
|
||||||
|
|||||||
@@ -113,10 +113,11 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
|
const requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
|
||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
function start() {
|
function start() {
|
||||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestOpptyProducts, runtimeApi });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function registerBackgroundMessages({
|
|||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
|
requestConsumptionPlanDetail,
|
||||||
requestOpptyProducts,
|
requestOpptyProducts,
|
||||||
runtimeApi
|
runtimeApi
|
||||||
}) {
|
}) {
|
||||||
@@ -115,6 +116,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestConsumptionPlanDetail) {
|
||||||
|
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load consumption plan details.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestConsumptionTrend) {
|
if (message.type === messageTypes.requestConsumptionTrend) {
|
||||||
requestConsumptionTrend(message)
|
requestConsumptionTrend(message)
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
|
|||||||
@@ -361,12 +361,26 @@ function createContentApplication(overrides = {}) {
|
|||||||
showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
|
const consumptionPlanDetailView = createConsumptionPlanDetailView({
|
||||||
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const consumptionPlanDetailController = createConsumptionPlanDetailController({
|
||||||
|
messageTypes,
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
renderPlanDetailBadges: (...args) => consumptionPlanDetailView.renderPlanDetailBadges(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
const consumptionPlansView = createConsumptionPlansView({
|
const consumptionPlansView = createConsumptionPlansView({
|
||||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||||
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
|
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||||
MODAL_ID: contracts.MODAL_ID,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
|
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
|
||||||
|
loadPlanDetailForPanel: (...args) => consumptionPlanDetailController.loadPlanDetailForPanel(...args),
|
||||||
|
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args),
|
||||||
createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
|
createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
|
||||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||||
@@ -374,6 +388,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const consumptionTrendController = createConsumptionTrendController({
|
const consumptionTrendController = createConsumptionTrendController({
|
||||||
|
createPlanDetailSection: (...args) => consumptionPlanDetailController.createPlanDetailSection(...args),
|
||||||
messageTypes,
|
messageTypes,
|
||||||
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
|
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
|
||||||
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
|
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||||
@@ -462,6 +477,8 @@ function createContentApplication(overrides = {}) {
|
|||||||
serviceRequestsAction,
|
serviceRequestsAction,
|
||||||
serviceRequestsView,
|
serviceRequestsView,
|
||||||
consumptionPlansView,
|
consumptionPlansView,
|
||||||
|
consumptionPlanDetailController,
|
||||||
|
consumptionPlanDetailView,
|
||||||
consumptionTrendController,
|
consumptionTrendController,
|
||||||
consumptionChart,
|
consumptionChart,
|
||||||
consumptionModel,
|
consumptionModel,
|
||||||
|
|||||||
63
src/features/consumption/plan-detail-controller.js
Normal file
63
src/features/consumption/plan-detail-controller.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
function createConsumptionPlanDetailController({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||||
|
appendBackgroundRequestLog, renderPlanDetailBadges, environment }) {
|
||||||
|
const { document } = environment;
|
||||||
|
const states = new WeakMap();
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
function createPlanDetailSection(plan) {
|
||||||
|
const section = document.createElement("div");
|
||||||
|
section.className = "opportunities-extension-plan-detail-badges";
|
||||||
|
section.dataset.subPlanNum = String(plan.subPlanNum || "").trim();
|
||||||
|
section.setAttribute("aria-live", "polite");
|
||||||
|
states.set(section, { status: "idle" });
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
async function request(subPlanNum) {
|
||||||
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
|
const response = await sendRuntimeMessage({ type: messageTypes.requestConsumptionPlanDetail, subPlanNum,
|
||||||
|
authorization: authorizations && authorizations.accountPlans });
|
||||||
|
appendBackgroundRequestLog(response?.debugRequests);
|
||||||
|
if (!response?.ok) throw new Error(response?.error || "Unable to load consumption plan details.");
|
||||||
|
return response.items;
|
||||||
|
}
|
||||||
|
function loadPlanDetailForPanel(panel, plan) {
|
||||||
|
const section = panel?.querySelector(".opportunities-extension-plan-detail-badges");
|
||||||
|
if (!section) return Promise.resolve();
|
||||||
|
const state = states.get(section);
|
||||||
|
if (state?.status === "ready") return Promise.resolve();
|
||||||
|
if (state?.status === "loading") return state.promise;
|
||||||
|
const subPlanNum = String(plan.subPlanNum || "").trim();
|
||||||
|
section.textContent = "";
|
||||||
|
section.setAttribute("aria-busy", "true");
|
||||||
|
const skeleton = document.createElement("span");
|
||||||
|
skeleton.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-plan-detail-skeleton";
|
||||||
|
skeleton.setAttribute("role", "status");
|
||||||
|
skeleton.setAttribute("aria-label", "Loading plan projection");
|
||||||
|
section.append(skeleton);
|
||||||
|
const current = { status: "loading", promise: null };
|
||||||
|
states.set(section, current);
|
||||||
|
current.promise = (async () => {
|
||||||
|
try {
|
||||||
|
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
|
||||||
|
if (!pending.has(subPlanNum)) pending.set(subPlanNum, request(subPlanNum).finally(() => pending.delete(subPlanNum)));
|
||||||
|
const items = await pending.get(subPlanNum);
|
||||||
|
if (!section.isConnected || states.get(section) !== current) return;
|
||||||
|
current.status = "ready";
|
||||||
|
renderPlanDetailBadges(section, items);
|
||||||
|
} catch (error) {
|
||||||
|
if (!section.isConnected || states.get(section) !== current) return;
|
||||||
|
current.status = "error";
|
||||||
|
section.textContent = "";
|
||||||
|
const notice = document.createElement("span");
|
||||||
|
notice.className = "opportunities-extension-plan-detail-error";
|
||||||
|
notice.setAttribute("role", "alert");
|
||||||
|
notice.textContent = error.message || "Unable to load plan details.";
|
||||||
|
section.append(notice);
|
||||||
|
} finally {
|
||||||
|
if (states.get(section) === current) section.setAttribute("aria-busy", "false");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return current.promise;
|
||||||
|
}
|
||||||
|
return { createPlanDetailSection, loadPlanDetailForPanel };
|
||||||
|
}
|
||||||
160
src/features/consumption/plan-detail-view.js
Normal file
160
src/features/consumption/plan-detail-view.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
function createConsumptionPlanDetailView({ createCloseIcon, environment }) {
|
||||||
|
const { document, window, setTimeout, clearTimeout } = environment;
|
||||||
|
let active = null;
|
||||||
|
let sequence = 0;
|
||||||
|
const fields = [
|
||||||
|
["pricingModel", "Pricing model"], ["lineStatus", "Line status"],
|
||||||
|
["startDate", "Start date"], ["endDate", "End date"],
|
||||||
|
["committedCredits", "Committed credits", true], ["consumed", "Consumed", true],
|
||||||
|
["consumptionL30Days", "Consumption last 30 days", true], ["monthsRemaining", "Months remaining"],
|
||||||
|
["projectedConsumption", "Projected consumption", true], ["projectedDelta", "Projected delta", true],
|
||||||
|
["orderId", "Order number"]
|
||||||
|
];
|
||||||
|
function node(tag, className, text) {
|
||||||
|
const result = document.createElement(tag);
|
||||||
|
if (className) result.className = className;
|
||||||
|
if (text !== undefined) result.textContent = text;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function statusBadge(record, tag = "span") {
|
||||||
|
const text = String(record.projectedStatus || "Unknown");
|
||||||
|
const badge = node(tag, "opportunities-extension-projection-badge", text);
|
||||||
|
badge.dataset.status = text.trim().toLowerCase();
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
function orderLink(record, text) {
|
||||||
|
try {
|
||||||
|
const url = new URL(record.orderTrackerURL);
|
||||||
|
if (url.origin !== "https://accountdashboard.oracle.com" || url.username || url.password) return null;
|
||||||
|
const link = node("a", "", text);
|
||||||
|
link.href = url.href;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
return link;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
function createLineDetails(record) {
|
||||||
|
const section = node("section", "opportunities-extension-projection-line");
|
||||||
|
const heading = node("h3");
|
||||||
|
heading.append(statusBadge(record));
|
||||||
|
section.append(heading);
|
||||||
|
const details = node("dl", "opportunities-extension-projection-fields");
|
||||||
|
fields.forEach(([key, label, numeric]) => {
|
||||||
|
const raw = record[key];
|
||||||
|
let text = raw === null || raw === undefined || raw === "" ? "-" : String(raw);
|
||||||
|
if (numeric && text !== "-" && Number.isFinite(Number(raw))) {
|
||||||
|
text = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(raw));
|
||||||
|
}
|
||||||
|
const group = node("div");
|
||||||
|
const value = node("dd");
|
||||||
|
const link = key === "orderId" && text !== "-" ? orderLink(record, text) : null;
|
||||||
|
if (link) value.append(link);
|
||||||
|
else value.textContent = text;
|
||||||
|
group.append(node("dt", "", label), value);
|
||||||
|
details.append(group);
|
||||||
|
});
|
||||||
|
section.append(details);
|
||||||
|
if (Array.isArray(record.clines) && record.clines.length) {
|
||||||
|
const children = node("div", "opportunities-extension-projection-children");
|
||||||
|
record.clines.forEach(child => children.append(createLineDetails(child)));
|
||||||
|
section.append(children);
|
||||||
|
}
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
function closePlanDetailPopover(restoreFocus = false) {
|
||||||
|
if (!active) return;
|
||||||
|
const previous = active;
|
||||||
|
active = null;
|
||||||
|
clearTimeout(previous.timer);
|
||||||
|
previous.popover.hidden = true;
|
||||||
|
previous.button.setAttribute("aria-expanded", "false");
|
||||||
|
document.removeEventListener("pointerdown", previous.outside, true);
|
||||||
|
window.removeEventListener("resize", previous.resize);
|
||||||
|
if (restoreFocus && previous.button.isConnected) {
|
||||||
|
previous.suppressFocus = true;
|
||||||
|
previous.button.focus();
|
||||||
|
previous.suppressFocus = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function positionPopover(entry) {
|
||||||
|
const rect = entry.button.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const width = Math.min(560, viewportWidth - 24);
|
||||||
|
const height = Math.min(520, viewportHeight - 24);
|
||||||
|
entry.popover.style.width = `${width}px`;
|
||||||
|
entry.popover.style.maxHeight = `${height}px`;
|
||||||
|
const below = viewportHeight - rect.bottom - 12;
|
||||||
|
const actualHeight = Math.min(entry.popover.getBoundingClientRect().height, height);
|
||||||
|
entry.popover.style.left = `${Math.max(12, Math.min(rect.left, viewportWidth - width - 12))}px`;
|
||||||
|
entry.popover.style.top = `${Math.max(12, below >= actualHeight ? rect.bottom + 4 : Math.min(rect.top - actualHeight - 4, viewportHeight - actualHeight - 12))}px`;
|
||||||
|
}
|
||||||
|
function createInteractiveBadge(record) {
|
||||||
|
const wrapper = node("div", "opportunities-extension-projection-wrapper");
|
||||||
|
const button = statusBadge(record, "button");
|
||||||
|
button.type = "button";
|
||||||
|
button.setAttribute("aria-label", `Projected status: ${button.textContent}. View plan details`);
|
||||||
|
button.setAttribute("aria-haspopup", "dialog");
|
||||||
|
button.setAttribute("aria-expanded", "false");
|
||||||
|
const popover = node("div", "opportunities-extension-projection-popover");
|
||||||
|
popover.id = `opportunities-extension-projection-${++sequence}`;
|
||||||
|
popover.hidden = true;
|
||||||
|
popover.setAttribute("role", "dialog");
|
||||||
|
popover.setAttribute("aria-label", "Consumption plan details");
|
||||||
|
popover.tabIndex = -1;
|
||||||
|
button.setAttribute("aria-controls", popover.id);
|
||||||
|
const dismiss = node("button", "opportunities-extension-projection-dismiss");
|
||||||
|
dismiss.type = "button";
|
||||||
|
dismiss.setAttribute("aria-label", "Close plan details");
|
||||||
|
dismiss.title = "Close plan details";
|
||||||
|
dismiss.append(createCloseIcon());
|
||||||
|
dismiss.addEventListener("click", () => closePlanDetailPopover(true));
|
||||||
|
popover.append(dismiss, createLineDetails(record));
|
||||||
|
const entry = { button, popover, wrapper, pinned: false, timer: null, suppressFocus: false,
|
||||||
|
outside: event => { if (!wrapper.contains(event.target)) closePlanDetailPopover(); },
|
||||||
|
resize: () => positionPopover(entry) };
|
||||||
|
function open() {
|
||||||
|
if (active !== entry) {
|
||||||
|
closePlanDetailPopover();
|
||||||
|
entry.pinned = false;
|
||||||
|
active = entry;
|
||||||
|
document.addEventListener("pointerdown", entry.outside, true);
|
||||||
|
window.addEventListener("resize", entry.resize);
|
||||||
|
}
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
popover.hidden = false;
|
||||||
|
button.setAttribute("aria-expanded", "true");
|
||||||
|
positionPopover(entry);
|
||||||
|
}
|
||||||
|
wrapper.addEventListener("pointerenter", open);
|
||||||
|
wrapper.addEventListener("pointerleave", () => {
|
||||||
|
if (entry.pinned || wrapper.contains(document.activeElement)) return;
|
||||||
|
entry.timer = setTimeout(() => { if (active === entry) closePlanDetailPopover(); }, 180);
|
||||||
|
});
|
||||||
|
wrapper.addEventListener("focusin", () => { if (!entry.suppressFocus) open(); });
|
||||||
|
wrapper.addEventListener("focusout", event => { if (!wrapper.contains(event.relatedTarget) && active === entry) closePlanDetailPopover(); });
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (active === entry && entry.pinned) closePlanDetailPopover();
|
||||||
|
else { open(); entry.pinned = true; }
|
||||||
|
});
|
||||||
|
wrapper.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Escape" && active === entry) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
closePlanDetailPopover(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wrapper.append(button, popover);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
function renderPlanDetailBadges(section, items) {
|
||||||
|
if (active && section.contains(active.button)) closePlanDetailPopover();
|
||||||
|
section.textContent = "";
|
||||||
|
if (!items.length) {
|
||||||
|
section.append(node("span", "opportunities-extension-projection-empty", "No projection data"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach(record => section.append(createInteractiveBadge(record)));
|
||||||
|
}
|
||||||
|
return { renderPlanDetailBadges, createLineDetails, closePlanDetailPopover };
|
||||||
|
}
|
||||||
47
src/features/consumption/plan-detail.css
Normal file
47
src/features/consumption/plan-detail.css
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
.opportunities-extension-plan-detail-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.opportunities-extension-consumption-trend-header { align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.opportunities-extension-consumption-trend-header > .opportunities-extension-plan-detail-badges { min-width: 0; }
|
||||||
|
.opportunities-extension-plan-detail-skeleton { width: 64px; height: 20px; }
|
||||||
|
.opportunities-extension-projection-badge {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; border: 1px solid transparent;
|
||||||
|
padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; line-height: 1.3;
|
||||||
|
color: #5f5a55; background: #eceae8; white-space: nowrap;
|
||||||
|
}
|
||||||
|
button.opportunities-extension-projection-badge { cursor: pointer; }
|
||||||
|
.opportunities-extension-projection-badge[data-status="forfeit"] { color: #a12424; background: #fbe5e5; }
|
||||||
|
.opportunities-extension-projection-badge[data-status="overage"] { color: #185a9d; background: #e0edfc; }
|
||||||
|
.opportunities-extension-projection-badge:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-projection-popover {
|
||||||
|
position: fixed; z-index: 8; box-sizing: border-box; padding: 14px; overflow: auto; overscroll-behavior: contain;
|
||||||
|
border: 1px solid #b9b4ae; border-radius: 4px; background: #ffffff; color: #312d2a;
|
||||||
|
box-shadow: 0 4px 16px #0003; font-size: 12px; line-height: 1.4; overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.opportunities-extension-projection-popover[hidden] { display: none; }
|
||||||
|
.opportunities-extension-projection-popover .opportunities-extension-projection-dismiss {
|
||||||
|
display: grid; place-items: center; width: 24px; height: 24px; margin-left: auto; margin-bottom: 8px; padding: 4px; border: 1px solid #b9b4ae;
|
||||||
|
border-radius: 4px; background: transparent; color: inherit; cursor: pointer; font-size: 12px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-projection-dismiss svg { width: 14px; height: 14px; }
|
||||||
|
.opportunities-extension-projection-line h3 { margin: 0 0 8px; font-size: 12px; }
|
||||||
|
.opportunities-extension-projection-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; margin: 0; }
|
||||||
|
.opportunities-extension-projection-fields > div { min-width: 0; }
|
||||||
|
.opportunities-extension-projection-fields dt { font-weight: 600; color: #5f5a55; font-size: 11px; }
|
||||||
|
.opportunities-extension-projection-fields dd { margin: 2px 0 0; font-variant-numeric: tabular-nums; }
|
||||||
|
.opportunities-extension-projection-fields a { color: #006d7a; text-decoration: underline; }
|
||||||
|
.opportunities-extension-projection-children { margin-top: 14px; padding-left: 12px; border-left: 2px solid #dedbd7; }
|
||||||
|
.opportunities-extension-projection-children > section + section { margin-top: 16px; }
|
||||||
|
.opportunities-extension-plan-detail-error { color: #a12424; font-size: 12px; }
|
||||||
|
.opportunities-extension-projection-empty { color: #5f5a55; font-size: 12px; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-popover { color: #f1efec; background: #302e2b; border-color: #716b64; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge { color: #dedbd7; background: #484541; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge[data-status="forfeit"] { color: #ffb9b9; background: #682e32; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge[data-status="overage"] { color: #c5dfff; background: #224b76; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-fields dt,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-empty { color: #cec9c2; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-fields a { color: #76dbea; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-plan-detail-error { color: #ffb9b9; }
|
||||||
|
@media (max-width: 480px) { .opportunities-extension-projection-fields { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.opportunities-extension-consumption-trend-header > .opportunities-extension-consumption-periods { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .opportunities-extension-plan-detail-skeleton { animation: none; } }
|
||||||
@@ -4,6 +4,8 @@ function createConsumptionPlansView({
|
|||||||
MODAL_ID,
|
MODAL_ID,
|
||||||
createCloseIcon,
|
createCloseIcon,
|
||||||
createConsumptionTrendSection,
|
createConsumptionTrendSection,
|
||||||
|
loadPlanDetailForPanel,
|
||||||
|
closePlanDetailPopover,
|
||||||
createSvgElement,
|
createSvgElement,
|
||||||
formatOracleResponseDate,
|
formatOracleResponseDate,
|
||||||
getNestedValue,
|
getNestedValue,
|
||||||
@@ -28,6 +30,7 @@ function createConsumptionPlansView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closePlanDetailPopover();
|
||||||
existingModal?.remove();
|
existingModal?.remove();
|
||||||
|
|
||||||
const modal = document.createElement("section");
|
const modal = document.createElement("section");
|
||||||
@@ -119,6 +122,7 @@ function createConsumptionPlansView({
|
|||||||
panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan));
|
panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan));
|
||||||
|
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
|
closePlanDetailPopover();
|
||||||
tabList.querySelectorAll("[role='tab']").forEach((candidate) => {
|
tabList.querySelectorAll("[role='tab']").forEach((candidate) => {
|
||||||
const isSelected = candidate === tab;
|
const isSelected = candidate === tab;
|
||||||
candidate.setAttribute("aria-selected", String(isSelected));
|
candidate.setAttribute("aria-selected", String(isSelected));
|
||||||
@@ -133,6 +137,7 @@ function createConsumptionPlansView({
|
|||||||
plan,
|
plan,
|
||||||
trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD
|
trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD
|
||||||
);
|
);
|
||||||
|
loadPlanDetailForPanel(panel, plan);
|
||||||
});
|
});
|
||||||
tabList.append(tab);
|
tabList.append(tab);
|
||||||
panels.append(panel);
|
panels.append(panel);
|
||||||
@@ -151,6 +156,7 @@ function createConsumptionPlansView({
|
|||||||
|
|
||||||
if (firstPanel) {
|
if (firstPanel) {
|
||||||
loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD);
|
loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD);
|
||||||
|
loadPlanDetailForPanel(firstPanel, plans[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,6 +353,7 @@ function createConsumptionPlansView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeAccountPlansModal() {
|
function closeAccountPlansModal() {
|
||||||
|
closePlanDetailPopover();
|
||||||
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||||
|
|
||||||
if (modal) {
|
if (modal) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ function createConsumptionTrendController({
|
|||||||
DEFAULT_CONSUMPTION_TREND_PERIOD,
|
DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||||
appendBackgroundRequestLog,
|
appendBackgroundRequestLog,
|
||||||
consumptionTrendState,
|
consumptionTrendState,
|
||||||
|
createPlanDetailSection,
|
||||||
renderConsumptionTrendChart,
|
renderConsumptionTrendChart,
|
||||||
requestConsumerTokenServiceOnce,
|
requestConsumerTokenServiceOnce,
|
||||||
sendRuntimeMessage,
|
sendRuntimeMessage,
|
||||||
@@ -44,7 +45,7 @@ function createConsumptionTrendController({
|
|||||||
const chart = document.createElement("div");
|
const chart = document.createElement("div");
|
||||||
chart.className = "opportunities-extension-consumption-chart";
|
chart.className = "opportunities-extension-consumption-chart";
|
||||||
chart.setAttribute("aria-live", "polite");
|
chart.setAttribute("aria-live", "polite");
|
||||||
header.append(periodControls);
|
header.append(createPlanDetailSection(plan), periodControls);
|
||||||
section.append(header, chart);
|
section.append(header, chart);
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function createDebugContentView({
|
|||||||
if (entry.name === "requestGenerateAiSummary") return "ai";
|
if (entry.name === "requestGenerateAiSummary") return "ai";
|
||||||
if (entry.name === "requestInstallBase") return "installBase";
|
if (entry.name === "requestInstallBase") return "installBase";
|
||||||
if (entry.name === "requestCustomerServiceRequest") return "serviceRequests";
|
if (entry.name === "requestCustomerServiceRequest") return "serviceRequests";
|
||||||
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService)/.test(entry.name)) return "consumption";
|
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail)/.test(entry.name)) return "consumption";
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,5 +54,30 @@ function createSpaConsumption({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { requestConsumptionTrend };
|
async function requestConsumptionPlanDetail(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
|
||||||
|
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
|
||||||
|
if (!subPlanNum) throw new Error("Sub plan number is required for plan details.");
|
||||||
|
if (!authorization.value) throw new Error("OCICONS-TS authorization is unavailable for plan details.");
|
||||||
|
const url = `https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/plandetail/${encodeURIComponent(subPlanNum)}?active=true`;
|
||||||
|
const items = await fetchJson(url, {
|
||||||
|
method: "GET", credentials: "include", cache: "no-store",
|
||||||
|
headers: { Accept: "application/json", "G2m-Authorization": authorization.value }
|
||||||
|
}, "requestConsumptionPlanDetail", debugRequests);
|
||||||
|
const validLines = (lines, depth = 0) => Array.isArray(lines) && depth <= 20 && lines.every(line =>
|
||||||
|
line && typeof line === "object" && !Array.isArray(line)
|
||||||
|
&& (line.clines === undefined || line.clines === null || validLines(line.clines, depth + 1)));
|
||||||
|
if (!validLines(items)) throw new Error("requestConsumptionPlanDetail returned invalid plan details.");
|
||||||
|
return { ok: true, subPlanNum, items, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
const entry = debugRequests[debugRequests.length - 1];
|
||||||
|
if (entry && entry.status === "success") { entry.status = "failed"; entry.error = error.message; }
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestConsumptionTrend, requestConsumptionPlanDetail };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ function createMessageTypes() {
|
|||||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
264
tests/consumptionPlanDetail.test.cjs
Normal file
264
tests/consumptionPlanDetail.test.cjs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { loadBackground, response, plain } = require("./support/characterization.cjs");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
const detail = {
|
||||||
|
projectedStatus: "Forfeit", pricingModel: "Funded Allocation", lineStatus: "ACTIVE",
|
||||||
|
startDate: "17-Aug-2023", endDate: "16-Aug-2027", committedCredits: 7488782,
|
||||||
|
consumed: 4106739, consumptionL30Days: 152200, monthsRemaining: null,
|
||||||
|
projectedConsumption: null, projectedDelta: null, currencyCode: "BRL", orderId: "SYNTH-ORDER",
|
||||||
|
orderTrackerURL: "https://accountdashboard.oracle.com/app/udb/?appId=12#/ordertracker/orderNumber/SYNTH-ORDER",
|
||||||
|
clines: [{ projectedStatus: "Overage", pricingModel: "Funded Allocation", monthsRemaining: 0,
|
||||||
|
committedCredits: 100, consumed: 110, projectedConsumption: 120, projectedDelta: -20,
|
||||||
|
orderId: "SYNTH-CHILD", orderTrackerURL: "https://accountdashboard.oracle.com/app/udb/#/ordertracker/orderNumber/SYNTH-CHILD" }]
|
||||||
|
};
|
||||||
|
const type = "opportunitiesExtension.requestConsumptionPlanDetail";
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: plan details use OCICONS value in G2m-Authorization, encoded subPlanNum, and debug`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const app = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (url.endsWith("consumerTokenService")) return response([
|
||||||
|
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
|
||||||
|
{ tokenService: "OCICONS-TS", uiaasHeader: { label: "unused-label", value: "synthetic-plan" } }
|
||||||
|
]);
|
||||||
|
return response([detail]);
|
||||||
|
} });
|
||||||
|
const auth = await app.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
||||||
|
const result = await app.dispatch({ type, subPlanNum: "plan/test", authorization: auth.authorizations.accountPlans });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(result.items, [detail]);
|
||||||
|
assert.equal(result.subPlanNum, "plan/test");
|
||||||
|
assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/plandetail/plan%2Ftest?active=true");
|
||||||
|
assert.equal(calls[1].options.method, "GET");
|
||||||
|
assert.equal(calls[1].options.credentials, "include");
|
||||||
|
assert.equal(calls[1].options.cache, "no-store");
|
||||||
|
assert.equal(calls[1].options.body, undefined);
|
||||||
|
assert.deepEqual(plain(calls[1].options.headers), { Accept: "application/json", "G2m-Authorization": "synthetic-plan" });
|
||||||
|
assert.equal(result.debugRequests[0].name, "requestConsumptionPlanDetail");
|
||||||
|
assert.equal(result.debugRequests[0].httpStatus, 200);
|
||||||
|
const content = loadApplication("content");
|
||||||
|
assert.doesNotMatch(content.debugContentLog.formatHeaderBlock(result.debugRequests[0].requestHeaders), /synthetic-plan/);
|
||||||
|
assert.equal(content.debugContentView.getRequestCategory(result.debugRequests[0]), "consumption");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("missing token/subPlanNum, HTTP, network, invalid JSON and malformed nested lines do not silently succeed", async () => {
|
||||||
|
for (const kind of ["token", "id", "http", "network", "json", "array", "clines"]) {
|
||||||
|
let calls = 0;
|
||||||
|
const app = loadBackground({ fetch: async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (kind === "http") return response({}, 403);
|
||||||
|
if (kind === "network") throw new Error("Synthetic network failure");
|
||||||
|
if (kind === "json") return new Response("<html>fallback</html>");
|
||||||
|
return response(kind === "array" ? {} : [{ clines: {} }]);
|
||||||
|
} });
|
||||||
|
const result = await app.dispatch({ type, subPlanNum: kind === "id" ? "" : "test",
|
||||||
|
authorization: { value: kind === "token" ? "" : "synthetic-plan" } });
|
||||||
|
assert.equal(result.ok, false, kind);
|
||||||
|
assert.ok(result.error, kind);
|
||||||
|
assert.equal(calls, ["token", "id"].includes(kind) ? 0 : 1);
|
||||||
|
if (calls) assert.equal(result.debugRequests[0].status, "failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createDom() {
|
||||||
|
const document = { activeElement: null, listeners: new Map(), documentElement: {} };
|
||||||
|
const window = { innerWidth: 420, innerHeight: 640, listeners: new Map() };
|
||||||
|
for (const target of [document, window]) {
|
||||||
|
target.addEventListener = (name, fn) => { if (!target.listeners.has(name)) target.listeners.set(name, new Set()); target.listeners.get(name).add(fn); };
|
||||||
|
target.removeEventListener = (name, fn) => target.listeners.get(name)?.delete(fn);
|
||||||
|
}
|
||||||
|
const walk = node => [node, ...node.children.flatMap(walk)];
|
||||||
|
class Element {
|
||||||
|
constructor(tag) {
|
||||||
|
this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.style = {}; this.dataset = {}; this.hidden = false; this.text = "";
|
||||||
|
this.classList = { add: name => { this.className = `${this.className || ""} ${name}`.trim(); } };
|
||||||
|
}
|
||||||
|
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
|
||||||
|
set textContent(value) { this.text = String(value); this.children.forEach(node => { node.parent = null; }); this.children = []; }
|
||||||
|
get textContent() { return this.text; }
|
||||||
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
|
getAttribute(key) { return this.attributes[key]; }
|
||||||
|
addEventListener(name, fn) { this.listeners[name] = fn; }
|
||||||
|
removeEventListener(name) { delete this.listeners[name]; }
|
||||||
|
focus() { document.activeElement = this; }
|
||||||
|
contains(node) { return !!node && walk(this).includes(node); }
|
||||||
|
remove() { if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this); this.parent = null; }
|
||||||
|
get isConnected() { return this === root || !!this.parent?.isConnected; }
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
return walk(this).slice(1).filter(node => selector.split(",").some(part => {
|
||||||
|
const value = part.trim();
|
||||||
|
if (value.startsWith(".")) return (node.className || "").split(" ").includes(value.slice(1));
|
||||||
|
const attr = value.match(/^\[([^=]+)='([^']+)'\]$/);
|
||||||
|
return attr ? node.attributes[attr[1]] === attr[2] : node.tag === value;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||||
|
getBoundingClientRect() { return this.tag === "button" ? { left: 350, top: 530, bottom: 550, height: 20 } : { height: 500 }; }
|
||||||
|
}
|
||||||
|
const root = new Element("main");
|
||||||
|
document.createElement = tag => new Element(tag);
|
||||||
|
document.createElementNS = (_, tag) => new Element(tag);
|
||||||
|
document.createTextNode = text => { const node = new Element("#text"); node.textContent = text; return node; };
|
||||||
|
document.getElementById = id => walk(root).find(node => node.id === id) || null;
|
||||||
|
const timers = new Map();
|
||||||
|
let timerId = 0;
|
||||||
|
return { root, document, window, walk, timers,
|
||||||
|
setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id) };
|
||||||
|
}
|
||||||
|
function setup() {
|
||||||
|
const dom = createDom();
|
||||||
|
const app = loadApplication("content", { ...dom, URL, Intl, requestAnimationFrame: () => {} });
|
||||||
|
dom.root.id = app.contracts.MODAL_ID;
|
||||||
|
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||||
|
return { ...dom, app };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("nested tooltip preserves values/null/zero and restricts order links to the expected HTTPS origin", () => {
|
||||||
|
const { app, root, walk } = setup();
|
||||||
|
app.consumptionPlanDetailView.renderPlanDetailBadges(root, [detail, { projectedStatus: "Other", pricingModel: "<img onerror=bad>", orderId: "bad", orderTrackerURL: "javascript:bad()" }]);
|
||||||
|
assert.equal(root.children.length, 2);
|
||||||
|
assert.equal(root.children[0].children[0].dataset.status, "forfeit");
|
||||||
|
const tooltip = root.children[0].children[1];
|
||||||
|
assert.equal(tooltip.hidden, true);
|
||||||
|
const nesting = tooltip.querySelector(".opportunities-extension-projection-children");
|
||||||
|
assert.ok(nesting);
|
||||||
|
assert.equal(nesting.querySelector(".opportunities-extension-projection-badge").dataset.status, "overage");
|
||||||
|
const terms = tooltip.querySelectorAll("dt");
|
||||||
|
assert.equal(terms.length, 22);
|
||||||
|
assert.ok(terms.some(term => term.textContent === "Order number"));
|
||||||
|
const values = tooltip.querySelectorAll("dd").map(value => value.textContent);
|
||||||
|
assert.ok(values.includes("7,488,782.00"));
|
||||||
|
assert.ok(values.includes("-20.00"));
|
||||||
|
assert.ok(values.includes("0"));
|
||||||
|
assert.ok(values.includes("-"));
|
||||||
|
assert.ok(values.includes("17-Aug-2023"));
|
||||||
|
assert.equal(root.querySelectorAll("a").length, 2);
|
||||||
|
assert.ok(root.querySelectorAll("a").every(link => link.target === "_blank" && link.rel === "noopener noreferrer"));
|
||||||
|
assert.ok(walk(root).some(node => node.textContent === "<img onerror=bad>"));
|
||||||
|
for (const orderTrackerURL of ["https://accountdashboard.oracle.com.evil.example/x", "https://user@accountdashboard.oracle.com/x", "http://accountdashboard.oracle.com/x"]) {
|
||||||
|
assert.equal(app.consumptionPlanDetailView.createLineDetails({ orderId: "test", orderTrackerURL }).querySelectorAll("a").length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hover, click, keyboard, outside dismissal and cleanup keep the popover within the viewport", () => {
|
||||||
|
const { app, root, document, window, timers } = setup();
|
||||||
|
app.consumptionPlanDetailView.renderPlanDetailBadges(root, [detail]);
|
||||||
|
const wrapper = root.children[0];
|
||||||
|
const [button, tooltip] = wrapper.children;
|
||||||
|
wrapper.listeners.pointerenter();
|
||||||
|
assert.equal(tooltip.hidden, false);
|
||||||
|
assert.equal(button.attributes["aria-expanded"], "true");
|
||||||
|
assert.ok(parseFloat(tooltip.style.left) + parseFloat(tooltip.style.width) <= window.innerWidth);
|
||||||
|
assert.ok(parseFloat(tooltip.style.top) >= 12);
|
||||||
|
wrapper.listeners.pointerleave();
|
||||||
|
assert.equal(timers.size, 1);
|
||||||
|
wrapper.listeners.pointerenter();
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
button.listeners.click();
|
||||||
|
wrapper.listeners.pointerleave();
|
||||||
|
assert.equal(timers.size, 0, "click pins the tooltip so the order links remain usable");
|
||||||
|
let stopped = false;
|
||||||
|
wrapper.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() { stopped = true; } });
|
||||||
|
assert.equal(stopped, true);
|
||||||
|
assert.equal(tooltip.hidden, true);
|
||||||
|
assert.equal(document.activeElement, button);
|
||||||
|
assert.equal(document.listeners.get("pointerdown").size, 0);
|
||||||
|
assert.equal(window.listeners.get("resize").size, 0);
|
||||||
|
wrapper.listeners.focusin();
|
||||||
|
for (const listener of document.listeners.get("pointerdown")) listener({ target: root });
|
||||||
|
assert.equal(tooltip.hidden, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("controller shares in-flight requests, preserves per-panel identity and ignores removed panels", async () => {
|
||||||
|
const { app, root, document } = setup();
|
||||||
|
const calls = [];
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ accountPlans: { value: "synthetic-plan" } });
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => calls.push({ message, resolve }));
|
||||||
|
const panel = document.createElement("section");
|
||||||
|
panel.append(app.consumptionPlanDetailController.createPlanDetailSection({ subPlanNum: "A" }));
|
||||||
|
root.append(panel);
|
||||||
|
const first = app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
|
||||||
|
const repeat = app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
|
||||||
|
assert.equal(first, repeat);
|
||||||
|
assert.equal(panel.children[0].attributes["aria-busy"], "true");
|
||||||
|
const other = document.createElement("section");
|
||||||
|
other.append(app.consumptionPlanDetailController.createPlanDetailSection({ subPlanNum: "B" }));
|
||||||
|
root.append(other);
|
||||||
|
const second = app.consumptionPlanDetailController.loadPlanDetailForPanel(other, { subPlanNum: "B" });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.deepEqual(plain(calls[0].message), { type, subPlanNum: "A", authorization: { value: "synthetic-plan" } });
|
||||||
|
calls[1].resolve({ ok: true, items: [{ projectedStatus: "Overage" }] });
|
||||||
|
await second;
|
||||||
|
panel.remove();
|
||||||
|
calls[0].resolve({ ok: true, items: [detail] });
|
||||||
|
await first;
|
||||||
|
assert.equal(other.querySelector(".opportunities-extension-projection-badge").textContent, "Overage");
|
||||||
|
assert.equal(panel.querySelector(".opportunities-extension-projection-badge"), null);
|
||||||
|
await app.consumptionPlanDetailController.loadPlanDetailForPanel(other, { subPlanNum: "B" });
|
||||||
|
assert.equal(calls.length, 2, "ready details remain in the current modal only");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Cloud Consumption loads details for the first tab and each newly selected plan, without delaying trends", () => {
|
||||||
|
const { app, root, document } = setup();
|
||||||
|
const calls = [];
|
||||||
|
const trends = [];
|
||||||
|
app.consumptionPlanDetailController.loadPlanDetailForPanel = (_, plan) => calls.push(plan.subPlanNum);
|
||||||
|
app.consumptionTrendController.loadConsumptionTrendForPanel = (_, plan) => trends.push(plan.subPlanNum);
|
||||||
|
app.consumptionPlansView.openAccountPlansModal({ OptyNumber: "SYNTH" }, [], "loading");
|
||||||
|
app.consumptionPlansView.openAccountPlansModal({ OptyNumber: "SYNTH" }, [{ subPlanNum: "A" }, { subPlanNum: "B" }], "ready");
|
||||||
|
assert.deepEqual(calls, ["A"]);
|
||||||
|
assert.deepEqual(trends, ["A"]);
|
||||||
|
const badges = root.querySelector(".opportunities-extension-plan-detail-badges");
|
||||||
|
const trendHeader = root.querySelector(".opportunities-extension-consumption-trend-header");
|
||||||
|
assert.equal(trendHeader.children[0], badges, "projection badges are left of the period controls");
|
||||||
|
assert.equal(trendHeader.children[1].className, "opportunities-extension-consumption-periods");
|
||||||
|
assert.deepEqual(trendHeader.children[1].children.map(button => button.textContent), ["Quarterly", "Monthly", "Weekly", "Daily"]);
|
||||||
|
assert.equal(root.querySelector(".opportunities-extension-account-plan-details").querySelector(".opportunities-extension-plan-detail-badges"), null);
|
||||||
|
app.consumptionPlanDetailView.renderPlanDetailBadges(badges, [detail]);
|
||||||
|
const wrapper = badges.children[0];
|
||||||
|
wrapper.listeners.pointerenter();
|
||||||
|
root.querySelectorAll("[role='tab']")[1].listeners.click();
|
||||||
|
assert.equal(wrapper.children[1].hidden, true, "switching tabs closes the previous tooltip");
|
||||||
|
assert.deepEqual(calls, ["A", "B"]);
|
||||||
|
assert.deepEqual(trends, ["A", "B"]);
|
||||||
|
assert.equal(root.querySelectorAll(".opportunities-extension-plan-detail-badges").length, 2);
|
||||||
|
app.consumptionPlansView.closeAccountPlansModal();
|
||||||
|
assert.equal(document.getElementById(app.contracts.ACCOUNT_PLANS_MODAL_ID).hidden, true);
|
||||||
|
assert.equal(document.listeners.get("pointerdown").size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a detail failure stays local and the next activation can retry and display the empty state", async () => {
|
||||||
|
const { app, root, document } = setup();
|
||||||
|
const panel = document.createElement("section");
|
||||||
|
const section = app.consumptionPlanDetailController.createPlanDetailSection({ subPlanNum: "A" });
|
||||||
|
panel.append(section);
|
||||||
|
root.append(panel);
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ accountPlans: { value: "synthetic-plan" } });
|
||||||
|
let count = 0;
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async () => ++count === 1
|
||||||
|
? { ok: false, error: "Synthetic detail failure" } : { ok: true, items: [] };
|
||||||
|
await app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
|
||||||
|
assert.equal(section.attributes["aria-busy"], "false");
|
||||||
|
assert.equal(section.children[0].attributes.role, "alert");
|
||||||
|
assert.equal(section.children[0].textContent, "Synthetic detail failure");
|
||||||
|
await app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
|
||||||
|
assert.equal(count, 2);
|
||||||
|
assert.equal(section.children[0].textContent, "No projection data");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plan projection CSS is scoped, has red/blue states in both themes and bounds only the tooltip scrolling", () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/consumption/plan-detail.css"), "utf8");
|
||||||
|
assert.match(css, /data-status="forfeit"/);
|
||||||
|
assert.match(css, /data-status="overage"/);
|
||||||
|
assert.match(css, /data-theme="dark"/);
|
||||||
|
assert.match(css, /position: fixed/);
|
||||||
|
const headerRule = css.match(/\.opportunities-extension-consumption-trend-header \{([^}]+)\}/)[1];
|
||||||
|
assert.match(headerRule, /justify-content: space-between/, "badges align with the chart's left edge while period controls stay right");
|
||||||
|
assert.match(css, /prefers-reduced-motion/);
|
||||||
|
assert.doesNotMatch(css, /\.opportunities-extension-account-plans-dialog\s*\{/);
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ test("request categories cover every current family and unknown requests stay vi
|
|||||||
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
|
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
|
||||||
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
|
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
|
||||||
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
|
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
|
||||||
|
requestConsumptionPlanDetail: "consumption",
|
||||||
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
|
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
|
||||||
requestConsumptionServiceWeekly: "consumption", requestConsumptionServiceDaily: "consumption", futureRequest: "other"
|
requestConsumptionServiceWeekly: "consumption", requestConsumptionServiceDaily: "consumption", futureRequest: "other"
|
||||||
})) assert.equal(h.app.debugContentView.getRequestCategory({ name }), category, name);
|
})) assert.equal(h.app.debugContentView.getRequestCategory({ name }), category, name);
|
||||||
@@ -94,7 +95,7 @@ test("tabs support arrows, Home and End with a single selected tab and labelled
|
|||||||
|
|
||||||
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
|
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css"]
|
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css"]
|
||||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
|
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
|
||||||
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));
|
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));
|
||||||
|
|||||||
@@ -158,6 +158,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
|||||||
assert.ok(remaining > 0);
|
assert.ok(remaining > 0);
|
||||||
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
|
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
|
||||||
}
|
}
|
||||||
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css"].map(read).join("\n");
|
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css"].map(read).join("\n");
|
||||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user