Implemented the customer rate card view, including SKU search

This commit is contained in:
2026-09-22 20:25:41 -03:00
parent 9ac5ffb11d
commit b4d8d9dbc1
23 changed files with 1854 additions and 130 deletions

View File

@@ -33,12 +33,34 @@ 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`, `src/features/debug/styles.css`, `src/features/opportunities/styles.css`,
`src/features/install-base/styles.css`, `src/features/service-requests/styles.css` `src/features/install-base/styles.css`, `src/features/service-requests/styles.css`
and `src/features/consumption/plan-detail.css` at `src/features/consumption/plan-detail.css` and `src/features/ratecard/styles.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
Each Cloud Consumption plan includes a **Rate card** button beside Contract ID.
It opens an animated right-side drawer with skeleton rows, an all-column search
and sortable headers. The drawer reuses the Cloud Consumption header component
with the title **Rate Card**, customer subtitle, and the active plan's Booking
Customer, Sold To Party Name, Contract ID, Header Status, Start Date and End Date.
There is no separate plan-number subtitle or nested Rate Card action; its Close button
dismisses only the drawer. `requestRatecardByPlan` sends the active plan's `subPlanNum`
as the payload's `planNumber`, with `recordsSize: 10000`, to the fixed SPA endpoint.
It uses the existing OCICONS-TS `uiaasHeader.value` in `G2m-Authorization` and is
logged in the Consumption debug category with the existing credential masking.
The eleven requested fields are displayed, including percentage discounts and
USD prices with at least four decimal places. Search includes raw and formatted
values; prices, quantities, percentages and dates sort by value. Only the drawer
table scrolls, horizontally when its available width is below 1100px.
Closing the drawer, changing plans or closing Cloud Consumption discards late UI
updates. Concurrent requests for the same plan are shared; reopening after completion
fetches again. Errors remain local. No new persisted cache, permissions, token
refresh or retries are added. The provided contract requests up to 10000 records;
the response's `searchAfter` is not used without a defined pagination contract.
`tests/ratecard.test.cjs` covers transport, formatting, filtering, sorting and
drawer lifecycle with synthetic responses and DOM, without launching browsers.
Cloud Consumption loads `requestConsumptionPlanDetail` for the active plan after Cloud Consumption loads `requestConsumptionPlanDetail` for the active plan after
account plans arrive, independently of the chart. Selecting another plan loads 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 its `subPlanNum` on demand. The GET uses `active=true` and the raw OCICONS-TS

View File

@@ -14,7 +14,8 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase", requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail" requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
}); });
} }
@@ -649,7 +650,31 @@ function createSpaConsumption({
} }
} }
return { requestConsumptionTrend, requestConsumptionPlanDetail }; async function requestRatecardByPlan(message) {
const debugRequests = [];
try {
const planNumber = typeof message.planNumber === "string" ? message.planNumber.trim() : "";
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
if (!planNumber) throw new Error("Plan number is required for the rate card.");
if (!authorization.value) throw new Error("OCICONS-TS authorization is unavailable for the rate card.");
const items = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/ratecard/byPlan", {
method: "POST", credentials: "include", cache: "no-store",
headers: { Accept: "application/json", "Content-Type": "application/json", "G2m-Authorization": authorization.value },
body: JSON.stringify({ planNumber, recordsSize: 10000 })
}, "requestRatecardByPlan", debugRequests);
if (!Array.isArray(items) || items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
throw new Error("requestRatecardByPlan returned invalid rate card items.");
}
return { ok: true, planNumber, 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, requestRatecardByPlan };
} }
// Source: src/services/spa/response-shapes.js // Source: src/services/spa/response-shapes.js
@@ -1607,6 +1632,7 @@ function registerBackgroundMessages({
requestConsumerTokenService, requestConsumerTokenService,
requestConsumptionTrend, requestConsumptionTrend,
requestConsumptionPlanDetail, requestConsumptionPlanDetail,
requestRatecardByPlan,
requestOpptyProducts, requestOpptyProducts,
runtimeApi runtimeApi
}) { }) {
@@ -1714,6 +1740,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; return true;
} }
if (message.type === messageTypes.requestRatecardByPlan) {
requestRatecardByPlan(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load rate card.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestConsumptionPlanDetail) { if (message.type === messageTypes.requestConsumptionPlanDetail) {
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({ requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load consumption plan details.", ok: false, error: error.message || "Unable to load consumption plan details.",
@@ -1857,10 +1891,11 @@ function createBackgroundApplication(overrides = {}) {
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 requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...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, requestConsumptionPlanDetail, requestOpptyProducts, runtimeApi }); startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
} }
return { return {
start, contracts, start, contracts,

File diff suppressed because one or more lines are too long

View File

@@ -14,7 +14,8 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase", requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail" requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
}); });
} }
@@ -649,7 +650,31 @@ function createSpaConsumption({
} }
} }
return { requestConsumptionTrend, requestConsumptionPlanDetail }; async function requestRatecardByPlan(message) {
const debugRequests = [];
try {
const planNumber = typeof message.planNumber === "string" ? message.planNumber.trim() : "";
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
if (!planNumber) throw new Error("Plan number is required for the rate card.");
if (!authorization.value) throw new Error("OCICONS-TS authorization is unavailable for the rate card.");
const items = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/ratecard/byPlan", {
method: "POST", credentials: "include", cache: "no-store",
headers: { Accept: "application/json", "Content-Type": "application/json", "G2m-Authorization": authorization.value },
body: JSON.stringify({ planNumber, recordsSize: 10000 })
}, "requestRatecardByPlan", debugRequests);
if (!Array.isArray(items) || items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
throw new Error("requestRatecardByPlan returned invalid rate card items.");
}
return { ok: true, planNumber, 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, requestRatecardByPlan };
} }
// Source: src/services/spa/response-shapes.js // Source: src/services/spa/response-shapes.js
@@ -1607,6 +1632,7 @@ function registerBackgroundMessages({
requestConsumerTokenService, requestConsumerTokenService,
requestConsumptionTrend, requestConsumptionTrend,
requestConsumptionPlanDetail, requestConsumptionPlanDetail,
requestRatecardByPlan,
requestOpptyProducts, requestOpptyProducts,
runtimeApi runtimeApi
}) { }) {
@@ -1714,6 +1740,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; return true;
} }
if (message.type === messageTypes.requestRatecardByPlan) {
requestRatecardByPlan(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load rate card.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestConsumptionPlanDetail) { if (message.type === messageTypes.requestConsumptionPlanDetail) {
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({ requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load consumption plan details.", ok: false, error: error.message || "Unable to load consumption plan details.",
@@ -1857,10 +1891,11 @@ function createBackgroundApplication(overrides = {}) {
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 requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...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, requestConsumptionPlanDetail, requestOpptyProducts, runtimeApi }); startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
} }
return { return {
start, contracts, start, contracts,

File diff suppressed because one or more lines are too long

View File

@@ -33,6 +33,7 @@ different from extension storage and is not a safe place for credentials.
| 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 | 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 | Rate card | Pure column/filter/sort model, controller with per-plan in-flight sharing, animated drawer with skeleton/search/table; response guards and cleanup on close or plan switch |
| 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 |
@@ -57,7 +58,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, consumption trend and plan detail authorization | Same service response, distinct credential from ACCP-TS | | OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans, consumption trend, plan detail and rate card 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
@@ -75,6 +76,7 @@ operation-specific headers rather than unifying all calls under a default token.
| 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 | | 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 |
| requestRatecardByPlan | POST `provider-proxy/g2m-consumption/service/v1/ratecard/byPlan` | `{planNumber,recordsSize:10000}`, where planNumber is the selected plan's subPlanNum; raw OCICONS-TS in fixed G2m-Authorization; response must be an array of records; no retries, cursor pagination or persistent 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.
@@ -100,6 +102,7 @@ was silently changed during extraction.
| 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 | | requestConsumptionPlanDetail | subPlanNum, authorization | ok, subPlanNum, items (including clines), debugRequests; error envelope on failure |
| requestRatecardByPlan | planNumber, authorization | ok, planNumber, items, 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

View File

@@ -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", "src/features/consumption/plan-detail.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", "src/features/ratecard/styles.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)};`);
} }

View File

@@ -45,6 +45,10 @@ 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/header-view.js",
"src/features/ratecard/model.js",
"src/features/ratecard/controller.js",
"src/features/ratecard/view.js",
"src/features/consumption/plan-detail-controller.js", "src/features/consumption/plan-detail-controller.js",
"src/features/consumption/plan-detail-view.js", "src/features/consumption/plan-detail-view.js",
"src/features/consumption/trend-controller.js", "src/features/consumption/trend-controller.js",

View File

@@ -114,10 +114,11 @@ function createBackgroundApplication(overrides = {}) {
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 requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...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, requestConsumptionPlanDetail, requestOpptyProducts, runtimeApi }); startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
} }
return { return {
start, contracts, start, contracts,

View File

@@ -9,6 +9,7 @@ function registerBackgroundMessages({
requestConsumerTokenService, requestConsumerTokenService,
requestConsumptionTrend, requestConsumptionTrend,
requestConsumptionPlanDetail, requestConsumptionPlanDetail,
requestRatecardByPlan,
requestOpptyProducts, requestOpptyProducts,
runtimeApi runtimeApi
}) { }) {
@@ -116,6 +117,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; return true;
} }
if (message.type === messageTypes.requestRatecardByPlan) {
requestRatecardByPlan(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load rate card.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestConsumptionPlanDetail) { if (message.type === messageTypes.requestConsumptionPlanDetail) {
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({ requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load consumption plan details.", ok: false, error: error.message || "Unable to load consumption plan details.",

View File

@@ -373,16 +373,42 @@ function createContentApplication(overrides = {}) {
renderPlanDetailBadges: (...args) => consumptionPlanDetailView.renderPlanDetailBadges(...args), renderPlanDetailBadges: (...args) => consumptionPlanDetailView.renderPlanDetailBadges(...args),
environment environment
}); });
const ratecardModel = createRatecardModel();
const consumptionHeaderView = createConsumptionHeaderView({
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
environment
});
const ratecardView = createRatecardView({
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
columns: ratecardModel.columns,
formatValue: (...args) => ratecardModel.formatValue(...args),
selectItems: (...args) => ratecardModel.selectItems(...args),
createPlanDetails: (...args) => consumptionHeaderView.createDetails(...args),
createConsumptionHeader: (...args) => consumptionHeaderView.createHeader(...args),
environment
});
const ratecardController = createRatecardController({
messageTypes,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
openRatecardDrawer: (...args) => ratecardView.openRatecardDrawer(...args),
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...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), createConsumptionHeader: (...args) => consumptionHeaderView.createHeader(...args),
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args), createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
loadPlanDetailForPanel: (...args) => consumptionPlanDetailController.loadPlanDetailForPanel(...args), loadPlanDetailForPanel: (...args) => consumptionPlanDetailController.loadPlanDetailForPanel(...args),
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args), closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args),
createRatecardButton: (...args) => ratecardController.createRatecardButton(...args),
closeRatecardDrawer: (...args) => ratecardView.closeRatecardDrawer(...args),
createSvgElement: (...args) => consumptionChart.createSvgElement(...args), createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), createPlanDetails: (...args) => consumptionHeaderView.createDetails(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args), loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args),
environment environment
@@ -479,6 +505,9 @@ function createContentApplication(overrides = {}) {
consumptionPlansView, consumptionPlansView,
consumptionPlanDetailController, consumptionPlanDetailController,
consumptionPlanDetailView, consumptionPlanDetailView,
ratecardModel,
ratecardView,
ratecardController,
consumptionTrendController, consumptionTrendController,
consumptionChart, consumptionChart,
consumptionModel, consumptionModel,

View File

@@ -0,0 +1,54 @@
function createConsumptionHeaderView({ createCloseIcon, formatOracleResponseDate, environment }) {
const { document } = environment;
function createHeader({ titleId, customerLabel, closeLabel, onClose, titleText = "Cloud Consumption" }) {
const header = document.createElement("header");
header.className = "opportunities-extension-account-plans-header";
const heading = document.createElement("div");
const title = document.createElement("h2");
title.id = titleId;
title.textContent = titleText;
const subtitle = document.createElement("p");
subtitle.textContent = customerLabel;
heading.append(title, subtitle);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", closeLabel);
closeButton.title = "Close";
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", onClose);
header.append(heading, closeButton);
return header;
}
function createDetails(plan, { contractAction = null } = {}) {
const details = document.createElement("dl");
details.className = "opportunities-extension-account-plan-details";
const fields = [
{ label: "Booking Customer", value: plan.bookingCustomer },
{ label: "Sold To Party Name", value: plan.soldToPartyName },
{ label: "Contract ID", value: plan.contractId },
{ label: "Header Status", value: plan.hdrStatusCode },
{ label: "Start Date", value: formatOracleResponseDate(plan.startDate) },
{ label: "End Date", value: formatOracleResponseDate(plan.endDate) }
];
fields.forEach(field => {
const group = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = field.label;
description.textContent = field.value === null || field.value === undefined || field.value === "" ? "-" : String(field.value);
if (field.label === "Contract ID" && contractAction) {
group.className = "opportunities-extension-contract-row";
const value = document.createElement("span");
value.textContent = description.textContent;
description.textContent = "";
description.className = "opportunities-extension-contract-ratecard";
description.append(value, contractAction);
}
group.append(term, description);
details.append(group);
});
return details;
}
return { createHeader, createDetails };
}

View File

@@ -2,12 +2,14 @@ function createConsumptionPlansView({
ACCOUNT_PLANS_MODAL_ID, ACCOUNT_PLANS_MODAL_ID,
DEFAULT_CONSUMPTION_TREND_PERIOD, DEFAULT_CONSUMPTION_TREND_PERIOD,
MODAL_ID, MODAL_ID,
createCloseIcon, createConsumptionHeader,
createConsumptionTrendSection, createConsumptionTrendSection,
loadPlanDetailForPanel, loadPlanDetailForPanel,
closePlanDetailPopover, closePlanDetailPopover,
createRatecardButton,
closeRatecardDrawer,
createSvgElement, createSvgElement,
formatOracleResponseDate, createPlanDetails,
getNestedValue, getNestedValue,
loadConsumptionTrendForPanel, loadConsumptionTrendForPanel,
environment environment
@@ -31,12 +33,14 @@ function createConsumptionPlansView({
} }
closePlanDetailPopover(); closePlanDetailPopover();
closeRatecardDrawer({ immediate: true, restoreFocus: false });
existingModal?.remove(); existingModal?.remove();
const modal = document.createElement("section"); const modal = document.createElement("section");
modal.id = ACCOUNT_PLANS_MODAL_ID; modal.id = ACCOUNT_PLANS_MODAL_ID;
modal.className = "opportunities-extension-account-plans-modal"; modal.className = "opportunities-extension-account-plans-modal";
modal.dataset.optyNumber = optyNumber; modal.dataset.optyNumber = optyNumber;
modal.dataset.customerLabel = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity";
modal.setAttribute("role", "dialog"); modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true"); modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title"); modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title");
@@ -45,26 +49,12 @@ function createConsumptionPlansView({
const dialog = document.createElement("div"); const dialog = document.createElement("div");
dialog.className = "opportunities-extension-account-plans-dialog"; dialog.className = "opportunities-extension-account-plans-dialog";
const header = document.createElement("header"); const header = createConsumptionHeader({
header.className = "opportunities-extension-account-plans-header"; titleId: "opportunities-extension-account-plans-title",
customerLabel: modal.dataset.customerLabel,
const heading = document.createElement("div"); closeLabel: "Close cloud consumption",
const title = document.createElement("h2"); onClose: closeAccountPlansModal
title.id = "opportunities-extension-account-plans-title"; });
title.textContent = "Cloud Consumption";
const subtitle = document.createElement("p");
subtitle.textContent = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity";
heading.append(title, subtitle);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Close cloud consumption");
closeButton.title = "Close";
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closeAccountPlansModal);
header.append(heading, closeButton);
const content = document.createElement("div"); const content = document.createElement("div");
content.className = "opportunities-extension-account-plans-content"; content.className = "opportunities-extension-account-plans-content";
@@ -123,6 +113,7 @@ function createConsumptionPlansView({
tab.addEventListener("click", () => { tab.addEventListener("click", () => {
closePlanDetailPopover(); closePlanDetailPopover();
closeRatecardDrawer({ immediate: true, restoreFocus: false });
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));
@@ -329,31 +320,12 @@ function createConsumptionPlansView({
} }
function createAccountPlanDetails(plan) { function createAccountPlanDetails(plan) {
const details = document.createElement("dl"); return createPlanDetails(plan, { contractAction: createRatecardButton(plan) });
details.className = "opportunities-extension-account-plan-details";
const fields = [
{ label: "Booking Customer", value: plan.bookingCustomer },
{ label: "Sold To Party Name", value: plan.soldToPartyName },
{ label: "Contract ID", value: plan.contractId },
{ label: "Header Status", value: plan.hdrStatusCode },
{ label: "Start Date", value: formatOracleResponseDate(plan.startDate) },
{ label: "End Date", value: formatOracleResponseDate(plan.endDate) }
];
fields.forEach((field) => {
const group = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = field.label;
description.textContent = field.value === null || field.value === undefined || field.value === "" ? "-" : String(field.value);
group.append(term, description);
details.append(group);
});
return details;
} }
function closeAccountPlansModal() { function closeAccountPlansModal() {
closePlanDetailPopover(); closePlanDetailPopover();
closeRatecardDrawer({ immediate: true, restoreFocus: false });
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (modal) { if (modal) {

View File

@@ -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|ConsumptionPlanDetail)/.test(entry.name)) return "consumption"; if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other"; return "other";
} }

View File

@@ -0,0 +1,38 @@
function createRatecardController({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, openRatecardDrawer, closePlanDetailPopover, environment }) {
const pending = new Map();
async function request(planNumber) {
const authorizations = await requestConsumerTokenServiceOnce();
const response = await sendRuntimeMessage({ type: messageTypes.requestRatecardByPlan, planNumber,
authorization: authorizations && authorizations.accountPlans });
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to load rate card.");
return response.items;
}
async function showRatecard(plan, trigger) {
closePlanDetailPopover();
const handle = openRatecardDrawer(plan, trigger);
if (!handle) return;
try {
const planNumber = String(plan.subPlanNum ?? "").trim();
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
if (!pending.has(planNumber)) pending.set(planNumber, request(planNumber).finally(() => pending.delete(planNumber)));
handle.showItems(await pending.get(planNumber));
} catch (error) {
handle.showError(error.message || "Unable to load rate card.");
}
}
function createRatecardButton(plan) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-ratecard-button";
button.textContent = "Rate Card";
button.title = "View rate card";
button.setAttribute("aria-haspopup", "dialog");
button.addEventListener("click", () => showRatecard(plan, button));
return button;
}
return { createRatecardButton, showRatecard };
}

View File

@@ -0,0 +1,65 @@
function createRatecardModel() {
const columns = [
{ key: "orderNumber", label: "Order Number", width: 7 },
{ key: "partNum", label: "Part Number", width: 6 },
{ key: "productName", label: "Product Name", width: 22 },
{ key: "uom", label: "UOM", width: 10 },
{ key: "subLnStartSt", label: "Start Date", type: "date", width: 8 },
{ key: "subLnEndDt", label: "End Date", type: "date", width: 8 },
{ key: "usageMax", label: "Usage Max", type: "number", width: 5 },
{ key: "rateCardDiscountPerc", label: "Discount (%)", type: "percent", width: 7 },
{ key: "listPriceUsd", label: "List price (USD)", type: "price", width: 9 },
{ key: "netUnitPriceUsd", label: "Net Unit Price (USD)", type: "price", width: 9 },
{ key: "overagePriceUsd", label: "Overage Net Unit Price (USD)", type: "price", width: 9 }
];
const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
const prices = new Intl.NumberFormat("en-US", { minimumFractionDigits: 4, maximumFractionDigits: 20 });
const numbers = new Intl.NumberFormat("en-US", { maximumFractionDigits: 20 });
const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
const missing = value => value === null || value === undefined || String(value).trim() === "" || String(value).trim() === "-";
const numeric = value => !missing(value) && Number.isFinite(Number(value)) ? Number(value) : null;
const normalize = value => String(value ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
function formatValue(item, column) {
const value = item[column.key];
if (missing(value)) return "-";
const number = numeric(value);
if (number !== null) {
if (column.type === "price") return prices.format(number);
if (column.type === "percent") return `${numbers.format(number)}%`;
if (column.type === "number") return numbers.format(number);
}
return String(value).trim();
}
function dateValue(value) {
const match = String(value).match(/^(\d{1,2})-([a-z]{3})-(\d{4})$/i);
if (match) {
const month = months.indexOf(match[2].toLowerCase());
return month < 0 ? null : Date.UTC(Number(match[3]), month, Number(match[1]));
}
const iso = String(value).match(/^(\d{4})-(\d{2})-(\d{2})(?:T.*)?$/);
return iso ? Date.UTC(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])) : null;
}
function selectItems(items, search = "", sortKey = "", direction = "asc") {
const query = normalize(search);
const filtered = items.filter(item => !query || columns.some(column =>
normalize(item[column.key]).includes(query) || normalize(formatValue(item, column)).includes(query)));
const column = columns.find(candidate => candidate.key === sortKey);
if (!column) return filtered;
const sortValue = item => {
const value = item[column.key];
if (missing(value)) return null;
if (column.type === "date") return dateValue(value);
return ["price", "number", "percent"].includes(column.type) ? numeric(value) : String(value);
};
return filtered.sort((a, b) => {
const left = sortValue(a), right = sortValue(b);
if (left === null || right === null) return left === right ? 0 : left === null ? 1 : -1;
const compared = typeof left === "number" ? left - right : collator.compare(left, right);
return direction === "desc" ? -compared : compared;
});
}
return { columns, formatValue, selectItems };
}

View File

@@ -0,0 +1,141 @@
.opportunities-extension-account-plan-details > .opportunities-extension-contract-row {
align-items: center;
}
.opportunities-extension-contract-row > dt,
.opportunities-extension-contract-row > dd,
.opportunities-extension-contract-ratecard > span {
line-height: 20px;
}
.opportunities-extension-contract-ratecard {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
}
.opportunities-extension-ratecard-button {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: center;
box-sizing: border-box;
height: 28px;
margin: 0;
flex: 0 0 auto;
padding: 0 8px;
border: 1px solid #8aaeb9;
border-radius: 4px;
background: #fff;
color: #006b80;
font: inherit;
font-size: 12px;
font-weight: 600;
line-height: 20px;
cursor: pointer;
}
.opportunities-extension-ratecard-button:hover { background: #e8f4f6; }
.opportunities-extension-ratecard-overlay {
position: absolute;
inset: 0;
z-index: 8;
display: flex;
justify-content: flex-end;
overflow: hidden;
overflow: clip;
background: rgba(0, 0, 0, 0);
transition: background-color 320ms ease;
}
.opportunities-extension-ratecard-overlay[data-open="true"] { background: rgba(0, 0, 0, .32); }
.opportunities-extension-ratecard-drawer {
--ratecard-bg: #fff;
--ratecard-heading: #faf9f8;
--ratecard-text: #312d2a;
--ratecard-muted: #615a55;
--ratecard-border: #dedbd8;
--ratecard-accent: #00758f;
--ratecard-skeleton: #eae7e4;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
width: min(1520px, calc(100% - 48px));
height: 100%;
min-width: 0;
box-sizing: border-box;
overflow: hidden;
background: var(--ratecard-bg);
color: var(--ratecard-text);
box-shadow: -6px 0 24px rgba(0, 0, 0, .2);
transform: translateX(100%);
transition: transform 320ms cubic-bezier(.22, .61, .36, 1);
}
.opportunities-extension-ratecard-overlay[data-open="true"] .opportunities-extension-ratecard-drawer { transform: translateX(0); }
.opportunities-extension-ratecard-header > div { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-ratecard-plan-details { padding: 12px 20px 0; }
.opportunities-extension-ratecard-toolbar { display: flex; align-items: center; gap: 16px; padding: 12px 20px; }
.opportunities-extension-ratecard-search {
width: min(360px, 100%);
min-width: 0;
box-sizing: border-box;
padding: 9px 12px;
border: 1px solid var(--ratecard-muted);
border-radius: 4px;
background: var(--ratecard-bg);
color: var(--ratecard-text);
font: inherit;
font-size: 13px;
}
.opportunities-extension-ratecard-search::placeholder { color: var(--ratecard-muted); opacity: 1; }
.opportunities-extension-ratecard-count { color: var(--ratecard-muted); font-size: 12px; white-space: nowrap; }
.opportunities-extension-ratecard-viewport { overflow: auto; min-height: 0; min-width: 0; scrollbar-gutter: stable; }
.opportunities-extension-ratecard-table { width: 100%; min-width: 1100px; table-layout: fixed; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.opportunities-extension-ratecard-table th, .opportunities-extension-ratecard-table td {
padding: 11px 10px;
border-bottom: 1px solid var(--ratecard-border);
vertical-align: top;
overflow-wrap: anywhere;
text-align: left;
}
.opportunities-extension-ratecard-table th { position: sticky; top: 0; z-index: 1; background: var(--ratecard-heading); }
.opportunities-extension-ratecard-sort {
display: grid;
grid-template-columns: minmax(0, 1fr) 12px;
align-items: center;
gap: 5px;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.opportunities-extension-ratecard-sort-icon { color: var(--ratecard-accent); text-align: center; }
.opportunities-extension-ratecard-table .opportunities-extension-ratecard-numeric { text-align: right; font-variant-numeric: tabular-nums; }
.opportunities-extension-ratecard-table .opportunities-extension-ratecard-notice { padding: 28px 20px; text-align: center; }
.opportunities-extension-ratecard-notice [role="alert"] { color: #b3261e; }
.opportunities-extension-ratecard-skeleton { display: block; width: 85%; height: 14px; border-radius: 3px; background: var(--ratecard-skeleton); animation: opportunities-ratecard-pulse 1.1s ease-in-out infinite alternate; }
.opportunities-extension-ratecard-drawer :focus-visible, .opportunities-extension-ratecard-button:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-ratecard-overlay[data-closing="true"] { pointer-events: none; background: rgba(0, 0, 0, 0); transition-duration: 180ms; }
.opportunities-extension-ratecard-overlay[data-closing="true"] .opportunities-extension-ratecard-drawer { transform: translateX(100%); transition-duration: 180ms; }
[data-theme="dark"] .opportunities-extension-ratecard-drawer {
--ratecard-bg: #292725;
--ratecard-heading: #35322f;
--ratecard-text: #f5f3f1;
--ratecard-muted: #c2bcb6;
--ratecard-border: #514c47;
--ratecard-accent: #59d1e5;
--ratecard-skeleton: #514c47;
}
[data-theme="dark"] .opportunities-extension-ratecard-button { color: #59d1e5; border-color: #6298a2; background: #292725; }
[data-theme="dark"] .opportunities-extension-ratecard-button:hover { background: #313f40; }
[data-theme="dark"] .opportunities-extension-ratecard-notice [role="alert"] { color: #ffb4ab; }
[data-theme="dark"] .opportunities-extension-ratecard-drawer :focus-visible, [data-theme="dark"] .opportunities-extension-ratecard-button:focus-visible { outline-color: #59d1e5; }
@keyframes opportunities-ratecard-pulse { from { opacity: .45; } to { opacity: 1; } }
@media (max-width: 720px) {
.opportunities-extension-ratecard-drawer { width: 100%; }
.opportunities-extension-ratecard-toolbar { flex-wrap: wrap; gap: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-ratecard-overlay, .opportunities-extension-ratecard-drawer, .opportunities-extension-ratecard-skeleton,
.opportunities-extension-ratecard-overlay[data-closing="true"], .opportunities-extension-ratecard-overlay[data-closing="true"] .opportunities-extension-ratecard-drawer { animation: none; transition: none; }
}

View File

@@ -0,0 +1,208 @@
function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, selectItems, createConsumptionHeader, createPlanDetails, environment }) {
const { document, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame } = environment;
let active = null;
let closing = null;
let closeTimer = null;
function closeRatecardDrawer({ immediate = false, restoreFocus = true } = {}) {
if (closeTimer !== null) clearTimeout(closeTimer);
closeTimer = null;
closing?.remove();
closing = null;
if (!active) return;
const previous = active;
active = null;
if (previous.openFrame !== null) cancelAnimationFrame(previous.openFrame);
previous.background.inert = previous.wasInert;
if (immediate) previous.root.remove();
else {
closing = previous.root;
closing.dataset.closing = "true";
closing.inert = true;
closeTimer = setTimeout(() => {
closing?.remove();
closing = null;
closeTimer = null;
}, 200);
}
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus({ preventScroll: true });
}
function openRatecardDrawer(plan, trigger) {
const parent = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
const background = parent?.querySelector(".opportunities-extension-account-plans-dialog");
if (!parent || parent.hidden || !background) return null;
closeRatecardDrawer({ immediate: true, restoreFocus: false });
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = `opportunities-extension-ratecard-${className}`;
if (text !== undefined) node.textContent = text;
return node;
};
const root = element("div", "overlay");
root.dataset.open = "false";
const panel = element("section", "drawer");
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", "opportunities-extension-ratecard-title");
panel.setAttribute("aria-busy", "true");
const header = createConsumptionHeader({
titleId: "opportunities-extension-ratecard-title",
titleText: "Rate Card",
customerLabel: parent.dataset.customerLabel,
closeLabel: "Close rate card",
onClose: () => closeRatecardDrawer()
});
header.classList.add("opportunities-extension-ratecard-header");
const close = header.querySelector("button");
const details = createPlanDetails(plan);
details.classList.add("opportunities-extension-ratecard-plan-details");
const toolbar = element("div", "toolbar");
const search = element("input", "search");
search.type = "search";
search.placeholder = "Search rate card";
search.setAttribute("aria-label", "Search rate card");
const count = element("span", "count", "Loading rate card...");
count.setAttribute("role", "status");
toolbar.append(search, count);
const viewport = element("div", "viewport");
viewport.tabIndex = 0;
viewport.setAttribute("role", "region");
viewport.setAttribute("aria-label", "Rate card items");
const table = element("table", "table");
const caption = element("caption", null, "Rate card items");
caption.className = "opportunities-extension-visually-hidden";
const colgroup = element("colgroup");
const head = element("thead");
const headRow = element("tr");
const sortHeaders = [];
const current = { root, background, wasInert: background.inert, trigger, items: [], status: "loading", sortKey: "", direction: "asc", openFrame: null };
active = current;
background.inert = true;
const alive = () => active === current && root.isConnected && !parent.hidden;
columns.forEach(column => {
const col = element("col");
col.style.width = `${column.width}%`;
colgroup.append(col);
const th = element("th");
th.scope = "col";
th.setAttribute("aria-sort", "none");
const sort = element("button", "sort");
sort.type = "button";
sort.title = `Sort by ${column.label}`;
const label = element("span", null, column.label);
const icon = element("span", "sort-icon", "\u2195");
icon.setAttribute("aria-hidden", "true");
sort.append(label, icon);
sort.addEventListener("click", () => {
current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc";
current.sortKey = column.key;
updateSortHeaders();
if (current.status === "ready") renderRows();
});
sortHeaders.push({ th, icon, key: column.key });
th.append(sort);
headRow.append(th);
});
head.append(headRow);
const body = element("tbody");
table.append(caption, colgroup, head, body);
viewport.append(table);
panel.append(header, details, toolbar, viewport);
root.append(panel);
parent.append(root);
function updateSortHeaders() {
sortHeaders.forEach(({ th, icon, key }) => {
const selected = key === current.sortKey;
th.setAttribute("aria-sort", selected ? current.direction === "asc" ? "ascending" : "descending" : "none");
icon.textContent = !selected ? "\u2195" : current.direction === "asc" ? "\u2191" : "\u2193";
});
}
function showNotice(message, error = false) {
body.textContent = "";
const row = element("tr");
const cell = element("td", "notice");
cell.colSpan = columns.length;
const text = element("p", null, message);
if (error) text.setAttribute("role", "alert");
cell.append(text);
row.append(cell);
body.append(row);
}
function renderRows() {
if (!alive()) return;
body.textContent = "";
const visible = selectItems(current.items, search.value, current.sortKey, current.direction);
count.textContent = `${visible.length} of ${current.items.length} items`;
if (!visible.length) {
showNotice(current.items.length ? "No matching rate card items." : "No rate card items found for this plan.");
}
visible.forEach(item => {
const row = element("tr");
columns.forEach(column => {
const cell = element("td", null, formatValue(item, column));
if (["price", "number", "percent"].includes(column.type)) cell.className = "opportunities-extension-ratecard-numeric";
row.append(cell);
});
body.append(row);
});
viewport.scrollTop = 0;
}
for (let index = 0; index < 7; index += 1) {
const row = element("tr");
row.setAttribute("aria-hidden", "true");
columns.forEach(() => {
const cell = element("td");
const skeleton = element("span", "skeleton");
cell.append(skeleton);
row.append(cell);
});
body.append(row);
}
search.addEventListener("input", () => { if (current.status === "ready") renderRows(); });
root.addEventListener("click", event => { if (event.target === root) closeRatecardDrawer(); });
root.addEventListener("keydown", event => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeRatecardDrawer();
} else if (event.key === "Tab") {
const controls = [close, search, viewport, ...headRow.querySelectorAll("button")];
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
event.preventDefault();
controls[event.shiftKey ? controls.length - 1 : 0].focus({ preventScroll: true });
}
}
});
// Focusing offscreen content must not scroll the clipped overlay and cancel the slide.
search.focus({ preventScroll: true });
// Paint the offscreen position before transitioning to the fully open position.
current.openFrame = requestAnimationFrame(() => {
if (!alive()) return;
current.openFrame = requestAnimationFrame(() => {
current.openFrame = null;
if (alive()) root.dataset.open = "true";
});
});
return {
showItems(items) {
if (!alive()) return;
current.items = items;
current.status = "ready";
panel.setAttribute("aria-busy", "false");
renderRows();
},
showError(message) {
if (!alive()) return;
current.status = "error";
panel.setAttribute("aria-busy", "false");
count.textContent = "Rate card unavailable";
showNotice(message, true);
}
};
}
return { openRatecardDrawer, closeRatecardDrawer };
}

View File

@@ -79,5 +79,29 @@ function createSpaConsumption({
} }
} }
return { requestConsumptionTrend, requestConsumptionPlanDetail }; async function requestRatecardByPlan(message) {
const debugRequests = [];
try {
const planNumber = typeof message.planNumber === "string" ? message.planNumber.trim() : "";
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
if (!planNumber) throw new Error("Plan number is required for the rate card.");
if (!authorization.value) throw new Error("OCICONS-TS authorization is unavailable for the rate card.");
const items = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/ratecard/byPlan", {
method: "POST", credentials: "include", cache: "no-store",
headers: { Accept: "application/json", "Content-Type": "application/json", "G2m-Authorization": authorization.value },
body: JSON.stringify({ planNumber, recordsSize: 10000 })
}, "requestRatecardByPlan", debugRequests);
if (!Array.isArray(items) || items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
throw new Error("requestRatecardByPlan returned invalid rate card items.");
}
return { ok: true, planNumber, 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, requestRatecardByPlan };
} }

View File

@@ -11,6 +11,7 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase", requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail" requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
}); });
} }

View File

@@ -29,6 +29,7 @@ test("request categories cover every current family and unknown requests stay vi
requestAccountSummary: "consumption", requestAccountPlans: "consumption", requestAccountSummary: "consumption", requestAccountPlans: "consumption",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests", requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
requestConsumptionPlanDetail: "consumption", requestConsumptionPlanDetail: "consumption",
requestRatecardByPlan: "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);
@@ -95,7 +96,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", "src/features/consumption/plan-detail.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", "src/features/ratecard/styles.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"));

View File

@@ -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", "src/features/consumption/plan-detail.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", "src/features/ratecard/styles.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)};`));
}); });

332
tests/ratecard.test.cjs Normal file
View File

@@ -0,0 +1,332 @@
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 type = "opportunitiesExtension.requestRatecardByPlan";
const item = {
orderNumber: "SYNTH-ORDER-2", partNum: "B-SYNTH-1", productName: "Synthetic GPU compute",
uom: "GPU PER HOUR", subLnStartSt: "30-Sep-2025", subLnEndDt: "27-May-2028",
usageMax: "-", rateCardDiscountPerc: "12.5", listPriceUsd: "4.3585",
netUnitPriceUsd: "0.9589", overagePriceUsd: "1.2501"
};
for (const family of ["chrome", "firefox"]) {
test(`${family}: rate card uses the active planNumber, OCICONS-TS and exact POST contract, with 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: "not-used", value: "synthetic-consumption" } }
]);
return response([item]);
} });
const auth = await app.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
const result = await app.dispatch({ type, planNumber: "PLAN-123", subPlanNum: "WRONG-ID", authorization: auth.authorizations.accountPlans });
assert.equal(result.ok, true);
assert.deepEqual(result.items, [item]);
assert.equal(result.planNumber, "PLAN-123");
assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/ratecard/byPlan");
assert.equal(calls[1].options.method, "POST");
assert.equal(calls[1].options.credentials, "include");
assert.equal(calls[1].options.cache, "no-store");
assert.deepEqual(JSON.parse(calls[1].options.body), { planNumber: "PLAN-123", recordsSize: 10000 });
assert.deepEqual(plain(calls[1].options.headers), {
Accept: "application/json", "Content-Type": "application/json", "G2m-Authorization": "synthetic-consumption"
});
const entry = result.debugRequests[0];
assert.equal(entry.name, "requestRatecardByPlan");
assert.equal(entry.httpStatus, 200);
assert.equal(entry.status, "success");
assert.ok(entry.startedAt && entry.completedAt);
assert.equal(typeof entry.durationMs, "number");
assert.deepEqual(JSON.parse(entry.requestBody), { planNumber: "PLAN-123", recordsSize: 10000 });
assert.match(entry.responseBody, /Synthetic GPU/);
const content = loadApplication("content");
assert.doesNotMatch(content.debugContentLog.formatHeaderBlock(entry.requestHeaders), /synthetic-consumption/);
assert.equal(content.debugContentView.getRequestCategory(entry), "consumption");
});
}
test("rate card fails locally for missing identifiers/credentials, HTTP, network, JSON and collection errors without retries", async () => {
for (const failure of ["id", "token", "401", "403", "network", "json", "shape", "item"]) {
let calls = 0;
const app = loadBackground({ fetch: async () => {
calls += 1;
if (["401", "403"].includes(failure)) return response({}, Number(failure));
if (failure === "network") throw new Error("Synthetic network failure");
if (failure === "json") return new Response("<html>fallback</html>");
return response(failure === "shape" ? {} : [null]);
} });
const result = await app.dispatch({ type, planNumber: failure === "id" ? "" : "A",
authorization: { value: failure === "token" ? "" : "synthetic" } });
assert.equal(result.ok, false, failure);
assert.ok(result.error, failure);
assert.equal(calls, ["id", "token"].includes(failure) ? 0 : 1, failure);
if (calls) assert.equal(result.debugRequests[0].status, "failed", failure);
}
});
test("all eleven columns are searchable, percentages and precise prices are formatted, and sorting is typed", () => {
const model = loadApplication("content").ratecardModel;
assert.equal(model.columns.length, 11);
for (const column of model.columns) {
assert.equal(model.selectItems([item], item[column.key]).length, 1, column.key);
assert.equal(model.selectItems([item], model.formatValue(item, column)).length, 1, column.key);
}
const format = (key, value) => model.formatValue({ [key]: value }, model.columns.find(column => column.key === key));
assert.equal(format("netUnitPriceUsd", "0.9589"), "0.9589");
assert.equal(format("listPriceUsd", 0), "0.0000");
assert.equal(format("rateCardDiscountPerc", 12.5), "12.5%");
assert.equal(format("usageMax", 0), "0");
assert.equal(format("subLnEndDt", null), "-");
assert.equal(model.selectItems([{ ...item, productName: "Synthet\u00edc" }], "synthetic").length, 1);
assert.equal(model.selectItems([{ ...item, hiddenMetadata: "secretmatch" }], "secretmatch").length, 0);
const rows = [
{ ...item, netUnitPriceUsd: "10", subLnStartSt: "01-Jan-2027" },
{ ...item, netUnitPriceUsd: "2", subLnStartSt: "31-Dec-2026" },
{ ...item, netUnitPriceUsd: null, subLnStartSt: null }
];
assert.deepEqual(plain(model.selectItems(rows, "", "netUnitPriceUsd").map(row => row.netUnitPriceUsd)), ["2", "10", null]);
assert.deepEqual(plain(model.selectItems(rows, "", "netUnitPriceUsd", "desc").map(row => row.netUnitPriceUsd)), ["10", "2", null]);
assert.deepEqual(plain(model.selectItems(rows, "", "subLnStartSt").map(row => row.subLnStartSt)), ["31-Dec-2026", "01-Jan-2027", null]);
assert.deepEqual(rows.map(row => row.netUnitPriceUsd), ["10", "2", null], "sorting does not mutate responses");
});
function setup() {
const document = { activeElement: null, documentElement: {} };
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.text = ""; this.value = ""; this.inert = false;
this.classList = { add: name => { this.className = `${this.className || ""} ${name}`; } };
}
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, callback) { this.listeners[name] = callback; }
focus(options) { document.activeElement = this; this.focusOptions = options; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(node => node !== 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; }
}
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();
const frames = new Map();
let id = 0;
const app = loadApplication("content", { document, window: { addEventListener() {} },
requestAnimationFrame: fn => { frames.set(++id, fn); return id; }, cancelAnimationFrame: key => frames.delete(key),
setTimeout: fn => { timers.set(++id, fn); return id; }, clearTimeout: key => timers.delete(key) });
root.id = app.contracts.MODAL_ID;
app.consumptionPlanDetailController.loadPlanDetailForPanel = () => {};
app.consumptionTrendController.loadConsumptionTrendForPanel = () => {};
const plans = [{ contractId: "CONTRACT-A", planNumber: "PLAN-A", subPlanNum: "SUB-A", bookingCustomer: "Booking A", soldToPartyName: "Sold A", hdrStatusCode: "ACTIVE", startDate: "23-Aug-2024", endDate: "22-Aug-2028" },
{ contractId: "CONTRACT-B", planNumber: "PLAN-B", subPlanNum: "SUB-B", bookingCustomer: "Booking B", soldToPartyName: "Sold B", hdrStatusCode: "EXPIRED", startDate: "01-Jan-2020", endDate: "01-Jan-2024" }];
const opportunity = { OptyNumber: "SYNTH", CustomerAccount: { PartyUniqueName: "Synthetic Customer" } };
app.consumptionPlansView.openAccountPlansModal(opportunity, [], "loading");
app.consumptionPlansView.openAccountPlansModal(opportunity, plans);
timers.clear();
frames.clear();
const logs = [], requests = [];
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(entries);
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ accountPlans: { value: "synthetic-plan" } });
app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => requests.push({ message, resolve }));
const find = suffix => root.querySelector(`.opportunities-extension-ratecard-${suffix}`);
const buttons = root.querySelectorAll(".opportunities-extension-ratecard-button");
const nextFrame = () => { const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach(fn => fn()); };
return { app, root, document, walk, timers, frames, nextFrame, plans, requests, logs, buttons, find };
}
const tick = () => new Promise(resolve => setImmediate(resolve));
test("Rate card sits beside Contract ID, opens a skeleton, searches and sorts without HTML injection", async () => {
const { app, root, buttons, find, document, requests, logs } = setup();
assert.equal(buttons.length, 2);
assert.equal(buttons[0].textContent, "Rate Card");
assert.equal(buttons[0].parent.parent.className, "opportunities-extension-contract-row");
assert.equal(buttons[0].parent.children[0].textContent, "CONTRACT-A");
const opening = buttons[0].listeners.click();
assert.equal(find("drawer").attributes["aria-busy"], "true");
assert.equal(root.querySelectorAll(".opportunities-extension-ratecard-skeleton").length, 77);
assert.equal(document.activeElement, find("search"));
await tick();
assert.deepEqual(plain(requests[0].message), { type, planNumber: "SUB-A", authorization: { value: "synthetic-plan" } });
const parentHeader = root.querySelector(".opportunities-extension-account-plans-header");
assert.equal(find("header").querySelector("h2").textContent, "Rate Card");
assert.equal(parentHeader.querySelector("h2").textContent, "Cloud Consumption");
assert.equal(find("header").querySelector("p").textContent, parentHeader.querySelector("p").textContent);
assert.equal(find("header").querySelector("p").textContent, "Synthetic Customer");
assert.notEqual(find("header").querySelector("h2").id, parentHeader.querySelector("h2").id);
assert.ok(find("header").className.includes("opportunities-extension-account-plans-header"));
const details = find("plan-details");
assert.deepEqual(details.querySelectorAll("dt").map(node => node.textContent), ["Booking Customer", "Sold To Party Name", "Contract ID", "Header Status", "Start Date", "End Date"]);
assert.deepEqual(details.querySelectorAll("dd").map(node => node.textContent), ["Booking A", "Sold A", "CONTRACT-A", "ACTIVE", "23-Aug-2024", "22-Aug-2028"]);
assert.equal(details.querySelectorAll("button").length, 0, "no recursive Rate Card action inside its own drawer");
const unsafe = "<img src=x onerror=bad()>";
requests[0].resolve({ ok: true, items: [{ ...item, netUnitPriceUsd: "10", productName: unsafe }, item], debugRequests: [{ name: "requestRatecardByPlan" }] });
await opening;
assert.equal(find("drawer").attributes["aria-busy"], "false");
assert.equal(find("count").textContent, "2 of 2 items");
assert.equal(root.querySelectorAll("img").length, 0);
const table = find("table");
assert.equal(table.querySelector("tbody").children[0].children[2].textContent, unsafe);
assert.equal(logs[0][0].name, "requestRatecardByPlan");
const priceSort = table.querySelectorAll("th")[9];
priceSort.children[0].listeners.click();
assert.equal(priceSort.attributes["aria-sort"], "ascending");
assert.equal(table.querySelector("tbody").children[0].children[9].textContent, "0.9589");
priceSort.children[0].listeners.click();
assert.equal(priceSort.attributes["aria-sort"], "descending");
assert.equal(table.querySelector("tbody").children[0].children[9].textContent, "10.0000");
find("search").value = "12.5%";
find("search").listeners.input();
assert.equal(find("count").textContent, "2 of 2 items");
find("search").value = "no match";
find("search").listeners.input();
assert.equal(find("count").textContent, "0 of 2 items");
assert.equal(find("notice").children[0].textContent, "No matching rate card items.");
find("header").querySelector("button").listeners.click();
assert.equal(root.querySelector(".opportunities-extension-account-plans-modal").hidden, undefined, "drawer close keeps Cloud Consumption open");
app.ratecardView.closeRatecardDrawer({ immediate: true });
});
test("quick clicks share a request, tab changes discard stale results, closing restores focus and keyboard stays inside", async () => {
const { app, root, buttons, requests, find, document, timers } = setup();
const first = buttons[0].listeners.click();
const repeat = buttons[0].listeners.click();
await tick();
assert.equal(requests.length, 1);
root.querySelectorAll("[role='tab']")[1].listeners.click();
assert.equal(find("overlay"), null);
const second = buttons[1].listeners.click();
await tick();
assert.equal(requests[1].message.planNumber, "SUB-B");
assert.deepEqual(find("plan-details").querySelectorAll("dd").map(node => node.textContent), ["Booking B", "Sold B", "CONTRACT-B", "EXPIRED", "01-Jan-2020", "01-Jan-2024"]);
requests[1].resolve({ ok: true, items: [item] });
await second;
requests[0].resolve({ ok: true, items: [{ ...item, productName: "WRONG PLAN" }] });
await Promise.all([first, repeat]);
assert.equal(find("table").querySelector("tbody").children[0].children[2].textContent, item.productName);
const background = root.querySelector(".opportunities-extension-account-plans-dialog");
assert.equal(background.inert, true);
const overlay = find("overlay");
const close = find("header").querySelector("button");
close.focus();
let prevented = 0, stopped = 0;
overlay.listeners.keydown({ key: "Tab", shiftKey: true, preventDefault() { prevented++; } });
assert.equal(document.activeElement, find("table").querySelectorAll("button")[10]);
overlay.listeners.keydown({ key: "Tab", preventDefault() { prevented++; } });
assert.equal(document.activeElement, close);
overlay.listeners.keydown({ key: "Escape", preventDefault() { prevented++; }, stopPropagation() { stopped++; } });
assert.equal(prevented, 3);
assert.equal(stopped, 1);
assert.equal(overlay.dataset.closing, "true");
assert.equal(document.activeElement, buttons[1]);
assert.equal(background.inert, false);
assert.equal(timers.size, 1);
const reopening = buttons[1].listeners.click();
assert.equal(timers.size, 0);
assert.equal(overlay.isConnected, false);
await tick();
app.consumptionPlansView.closeAccountPlansModal();
assert.equal(find("overlay"), null);
requests[2].resolve({ ok: true, items: [item] });
await reopening;
assert.equal(find("overlay"), null, "late responses never reopen a closed drawer");
});
test("missing subPlanNum does not use another identifier; errors are local, empty results and retry on reopening work", async () => {
const { app, plans, buttons, requests, find } = setup();
delete plans[0].subPlanNum;
await buttons[0].listeners.click();
assert.match(find("notice").children[0].textContent, /Plan number is unavailable/);
assert.equal(requests.length, 0);
const failed = buttons[1].listeners.click();
await tick();
requests[0].resolve({ ok: false, error: "Synthetic 401", debugRequests: [] });
await failed;
assert.equal(find("notice").children[0].attributes.role, "alert");
assert.equal(find("notice").children[0].textContent, "Synthetic 401");
const retry = buttons[1].listeners.click();
await tick();
requests[1].resolve({ ok: true, items: [] });
await retry;
assert.equal(find("notice").children[0].textContent, "No rate card items found for this plan.");
find("overlay").listeners.click({ target: find("overlay") });
app.ratecardView.closeRatecardDrawer({ immediate: true });
assert.equal(find("overlay"), null);
});
test("opening a plan without planNumber sends its subPlanNum as the payload planNumber", async () => {
const { plans, buttons, requests } = setup();
delete plans[0].planNumber;
const opening = buttons[0].listeners.click();
await tick();
assert.equal(requests.length, 1);
assert.equal(requests[0].message.planNumber, "SUB-A");
requests[0].resolve({ ok: true, items: [] });
await opening;
});
test("drawer starts offscreen, opens after a painted frame and never scrolls when focused", () => {
const { app, buttons, plans, find, frames, nextFrame } = setup();
app.ratecardView.openRatecardDrawer(plans[0], buttons[0]);
const overlay = find("overlay");
assert.equal(overlay.dataset.open, "false");
assert.equal(find("search").focusOptions.preventScroll, true);
nextFrame();
assert.equal(overlay.dataset.open, "false", "offscreen position gets one full paint");
nextFrame();
assert.equal(overlay.dataset.open, "true");
assert.equal(frames.size, 0);
app.ratecardView.closeRatecardDrawer({ immediate: true });
assert.equal(buttons[0].focusOptions.preventScroll, true);
for (const painted of [false, true]) {
app.ratecardView.openRatecardDrawer(plans[0], buttons[0]);
const cancelled = find("overlay");
if (painted) nextFrame();
app.ratecardView.closeRatecardDrawer({ immediate: true });
assert.equal(frames.size, 0);
nextFrame();
assert.equal(cancelled.dataset.open, "false", "closing cancels pending entry frames");
assert.equal(cancelled.isConnected, false);
}
});
test("drawer CSS supports both themes, reduced motion, bounded scrolling and separate header icon space", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/ratecard/styles.css"), "utf8");
assert.match(css, /data-theme="dark"/);
const buttonStyle = css.match(/\.opportunities-extension-ratecard-button \{([^}]+)\}/)[1];
assert.match(buttonStyle, /display: inline-flex/);
assert.match(buttonStyle, /align-items: center/);
assert.match(buttonStyle, /height: 28px/);
assert.match(buttonStyle, /line-height: 20px/);
assert.match(css, /\.opportunities-extension-contract-ratecard > span \{\s*line-height: 20px/);
assert.match(css, /prefers-reduced-motion/);
assert.match(css, /translateX\(100%\)/);
assert.match(css, /transition: transform 320ms/);
assert.match(css, /\[data-open="true"\] \.opportunities-extension-ratecard-drawer \{ transform: translateX\(0\); \}/);
assert.match(css, /overflow: clip/, "the overlay cannot be scrolled by focus during entry");
assert.doesNotMatch(css, /opportunities-ratecard-(?:fade|slide|backdrop)-in/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
assert.match(css, /min-width: 1100px/);
assert.match(css, /grid-template-rows: auto auto auto minmax\(0, 1fr\)/);
assert.doesNotMatch(css, /\.opportunities-extension-account-plans-dialog\s*\{/);
});