Implemented the customer rate card view, including SKU search
This commit is contained in:
24
README.md
24
README.md
@@ -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
|
||||||
|
|||||||
41
dist/chromium/background.js
vendored
41
dist/chromium/background.js
vendored
@@ -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,
|
||||||
|
|||||||
445
dist/chromium/content.js
vendored
445
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
41
dist/firefox/background.js
vendored
41
dist/firefox/background.js
vendored
@@ -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,
|
||||||
|
|||||||
445
dist/firefox/content.js
vendored
445
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -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)};`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
54
src/features/consumption/header-view.js
Normal file
54
src/features/consumption/header-view.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
src/features/ratecard/controller.js
Normal file
38
src/features/ratecard/controller.js
Normal 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 };
|
||||||
|
}
|
||||||
65
src/features/ratecard/model.js
Normal file
65
src/features/ratecard/model.js
Normal 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 };
|
||||||
|
}
|
||||||
141
src/features/ratecard/styles.css
Normal file
141
src/features/ratecard/styles.css
Normal 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; }
|
||||||
|
}
|
||||||
208
src/features/ratecard/view.js
Normal file
208
src/features/ratecard/view.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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
332
tests/ratecard.test.cjs
Normal 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*\{/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user