15 Commits

107 changed files with 15724 additions and 962 deletions

View File

@@ -1,6 +1,25 @@
# Opportunities Extension 1.0.2
# Opportunities Extension 1.0.6
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table.
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar.
## Opportunity Views
**Visao geral** is the default table view. **Calendario** displays opportunities
by Close Date (`EffectiveDate`) with month and week views only. Both views share
Reference, search and all existing filters; switching views does not send requests.
Events use date-only values without time-zone shifts, stage colors, and a read-only
detail with an opportunity link and copy button. Invalid or missing dates appear
in a separate list. Calendar navigation does not change the selected Reference.
Each day shows a filtered opportunity count, at most two events and an overflow
link to the remaining events. The grid fits the available height and reduces
visible events to one or zero when needed to keep every week visible. Missing
close dates are available in a collapsible list below the calendar.
Fiscal labels use `FY27-Q2-W19`: June-May fiscal
years, Sunday-Saturday weeks, and W1 for the week containing June 1. A quarter is
assigned by the week's Sunday, except the first fiscal week which belongs to Q1.
FullCalendar Standard 6.1.19 is bundled locally under `src/vendor/fullcalendar/`
with its MIT license. It is initialized only on first use, without a runtime CDN.
## Development Structure
@@ -33,9 +52,24 @@ On the first Opportunities modal opening in a page session, `requestCurrentUser`
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
do not trigger CRM Service Request or Workload Actions queries or see their badges.
If identity or roles cannot be validated, only these two features stay disabled;
other opportunity features continue. Reload the page to refresh identity/roles.
The unused identity token is discarded and redacted from debug responses.
Identity must contain a valid user (id, name, roles) and token before dependent
requests start. Failed identity validation keeps those requests waiting and retries
with backoff. Reload the page to refresh identity/roles.
The identity token is retained in debug responses and hidden by default; the
explicit sensitive-values toggle reveals the original value.
After an initial identity failure, recovery opens `https://spa.oracle.com/` in one
inactive auxiliary tab. The top-frame-only `spa-page.js` content script inspects
the page without sending HTTP requests. It rejects error pages, SAML/login forms,
OAuth callbacks and incomplete documents; cookies, HTTP 200 and elapsed time alone
do not establish readiness. After a stable loaded document is verified, identity
is fetched in that tab's same-origin context, at most once per document. A failed
read requires a fresh verified navigation before retrying. Rejected callbacks
restart at the SPA home URL, never at the one-use callback URL. A two-minute
readiness timeout ends the cycle without an additional API probe. The owned tab
and observers are cleaned up on success or failure. Debug metadata records the
page state, wait reason and `spa-tab` execution context; sensitive values remain
hidden by default. Reload the extension after changes to the manifest or script.
Consumer token recovery also handles HTTP 401/403, in addition to HTTP 200 with
invalid JSON. It opens the existing SPA session URL in an inactive temporary tab,
@@ -249,8 +283,9 @@ HTTP methods/statuses, request/response sections and JSON tokens. Highlighting
uses safe text nodes only after the existing sensitive-value masking. Compact JSON,
supported protection prefixes and JSON documents wrapped in strings are normalized
and indented, including while sensitive values are visible. Large JSON responses
are captured as bounded, valid JSON previews with an explicit omission notice,
preserving indentation, highlighting and sensitive-field masking. Already incomplete
are captured in full, preserving indentation, highlighting and sensitive-field
masking. Original sensitive values are retained for explicit reveal, including
identity, tokenrelay and catalog tokens. Already incomplete
or invalid JSON remains plain text. The visibility toggle retains its existing
behavior. Focus, expansion and timeline scroll position survive log updates.
@@ -316,6 +351,46 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
## Release 1.0.3 - 2026-10-13
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
- Added searchable Action forms, required-field validation, saving and local badge updates.
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
## Release 1.0.4 - 2026-10-19
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.5 - 2026-10-20
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.6 - 2026-10-21
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
## Loading in Development Mode
### Chrome or Microsoft Edge

File diff suppressed because it is too large Load Diff

2836
dist/chromium/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,12 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.2",
"version": "1.0.6",
"permissions": [
"cookies",
"storage"
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
"host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*",
@@ -23,6 +25,16 @@
],
"all_frames": true,
"run_at": "document_idle"
},
{
"matches": [
"https://spa.oracle.com/*"
],
"js": [
"spa-page.js"
],
"all_frames": false,
"run_at": "document_idle"
}
],
"background": {

58
dist/chromium/spa-page.js vendored Normal file
View File

@@ -0,0 +1,58 @@
(function () {
"use strict";
// Source: src/auth/spa-page.js
function createSpaPage() {
function start() {
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
if (!runtime) return;
const documentId = `${performance.timeOrigin}:${location.pathname}`;
let identityRequest = null;
function inspect() {
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
let status = "loading";
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
else if (document.readyState === "complete" && text
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
return { status, documentId };
}
runtime.onMessage.addListener((message, sender, reply) => {
if (sender.id !== runtime.id) return false;
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
reply(inspect());
return false;
}
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
const page = inspect();
if (page.status !== "ready" || page.documentId !== message.documentId) {
reply({ error: "SPA page is not ready or has navigated." });
return false;
}
// A document is probed at most once; failed identity reads require a fresh navigation.
if (!identityRequest) identityRequest = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
});
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
contentType: response.headers.get("content-type") || "", body: await response.text() };
} catch (error) {
return { error: error.message || "Unable to read SPA identity." };
} finally { clearTimeout(timer); }
})();
identityRequest.then(reply);
return true;
});
}
return { start };
}
createSpaPage().start();
})();

File diff suppressed because it is too large Load Diff

2836
dist/firefox/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,12 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.2",
"version": "1.0.6",
"permissions": [
"cookies",
"storage"
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
"host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*",
@@ -23,6 +25,16 @@
],
"all_frames": true,
"run_at": "document_idle"
},
{
"matches": [
"https://spa.oracle.com/*"
],
"js": [
"spa-page.js"
],
"all_frames": false,
"run_at": "document_idle"
}
],
"background": {

58
dist/firefox/spa-page.js vendored Normal file
View File

@@ -0,0 +1,58 @@
(function () {
"use strict";
// Source: src/auth/spa-page.js
function createSpaPage() {
function start() {
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
if (!runtime) return;
const documentId = `${performance.timeOrigin}:${location.pathname}`;
let identityRequest = null;
function inspect() {
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
let status = "loading";
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
else if (document.readyState === "complete" && text
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
return { status, documentId };
}
runtime.onMessage.addListener((message, sender, reply) => {
if (sender.id !== runtime.id) return false;
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
reply(inspect());
return false;
}
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
const page = inspect();
if (page.status !== "ready" || page.documentId !== message.documentId) {
reply({ error: "SPA page is not ready or has navigated." });
return false;
}
// A document is probed at most once; failed identity reads require a fresh navigation.
if (!identityRequest) identityRequest = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
});
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
contentType: response.headers.get("content-type") || "", body: await response.text() };
} catch (error) {
return { error: error.message || "Unable to read SPA identity." };
} finally { clearTimeout(timer); }
})();
identityRequest.then(reply);
return true;
});
}
return { start };
}
createSpaPage().start();
})();

View File

@@ -14,6 +14,11 @@ preexisting risks are tracked in [refactoring.md](refactoring.md).
uses `background.scripts`. Neither entry point is declared as a module.
- Content scripts run at `document_idle`, in all matching frames on the two
configured Fusion welcome pages. Their code also checks the hostname/path.
- `src/auth/spa-page.js` is a separate top-frame-only SPA content script, copied
into both distributions. It handles only extension messages for DOM readiness
inspection and a fixed same-origin identity GET; it sends no requests on its own.
`services/spa/page-client.js` connects this receiver to the background recovery
flow and the existing debug envelope. No page-world script is injected.
- There are no extension UI pages, injected page-context scripts, custom CSP,
declared execution world, or web-accessible resources.
- Permissions remain `cookies` and `storage`, with host access limited to the

View File

@@ -12,7 +12,7 @@ pass until executed and recorded separately for each browser.
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
directory and version 1.0.2; avoid testing a previously installed stale copy.
directory and version 1.0.6; avoid testing a previously installed stale copy.
- Keep debug secrets hidden. Record only status codes, request names, durations
and sanitized errors. Do not export real tokens, response bodies or account data.
- AI generation and tests requiring a deliberately expired session must use an

View File

@@ -1,6 +1,6 @@
{
"name": "opportunities-extension",
"version": "1.0.2",
"version": "1.0.6",
"private": true,
"type": "module",
"scripts": {

View File

@@ -8,16 +8,19 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const distDir = path.join(rootDir, "dist");
const contentScript = composition.composeScript("content");
const backgroundScript = composition.composeScript("background");
const spaPageScript = composition.composeScript("spa-page");
const checkOnly = process.argv.includes("--check");
const baseManifest = {
manifest_version: 3,
name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.2",
version: "1.0.6",
permissions: [
"cookies",
"storage"
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
host_permissions: [
"https://eeho.fa.us2.oraclecloud.com/*",
@@ -33,6 +36,12 @@ const baseManifest = {
js: ["content.js"],
all_frames: true,
run_at: "document_idle"
},
{
matches: ["https://spa.oracle.com/*"],
js: ["spa-page.js"],
all_frames: false,
run_at: "document_idle"
}
]
};
@@ -68,7 +77,7 @@ for (const target of targets) {
JSON.parse(manifestJson);
if (checkOnly) {
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript };
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript };
for (const [file, contents] of Object.entries(expected)) {
const actual = await readFile(path.join(distDir, target.name, file), "utf8");
if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
@@ -80,6 +89,7 @@ for (const target of targets) {
await mkdir(outputDir, { recursive: true });
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
await output.writeOutput(path.join(outputDir, "spa-page.js"), spaPageScript);
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
}

View File

@@ -2,6 +2,7 @@ const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const sources = require("./sources.cjs");
const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js"], background: [] };
const root = path.resolve(__dirname, "..");
@@ -16,15 +17,19 @@ function composeScript(target, { bootstrap = true } = {}) {
return `// Source: ${file}\n${source}`;
});
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", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"]
const calendarLibrary = fs.readFileSync(path.join(root, vendorSources.content[0]), "utf8");
const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8");
parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`);
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", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css")
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
}
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication";
const factory = { content: "createContentApplication", background: "createBackgroundApplication", "spa-page": "createSpaPage" }[target];
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
new vm.Script(output, { filename: `${target}.js` });
return output;
}
module.exports = { composeScript, sources };
module.exports = { composeScript, sources, vendorSources };

View File

@@ -1,5 +1,8 @@
module.exports = {
"spa-page": ["src/auth/spa-page.js"],
"content": [
"src/shared/time-dates.js",
"src/features/manage-time/view.js",
"src/shared/messages.js",
"src/shared/debug-body.js",
"src/content/contracts.js",
@@ -9,6 +12,7 @@ module.exports = {
"src/features/opportunities/table-state.js",
"src/features/opportunities/filter-state.js",
"src/features/preferences/state.js",
"src/features/preferences/request-cache.js",
"src/features/products/content-state.js",
"src/features/ai-summary/state.js",
"src/features/consumption/plans-state.js",
@@ -39,11 +43,17 @@ module.exports = {
"src/features/opportunities/activity-progress.js",
"src/features/opportunities/activity-filters.js",
"src/features/opportunities/crm-sr-popover.js",
"src/features/opportunities/crm-sr-form.js",
"src/features/opportunities/internal-sr-form.js",
"src/features/opportunities/searchable-select.js",
"src/features/opportunities/internal-sr-session.js",
"src/features/opportunities/sr-session-preparation.js",
"src/features/opportunities/workload-actions.js",
"src/features/opportunities/workload-action-form.js",
"src/features/opportunities/workload-action-options.js",
"src/features/opportunities/fiscal-periods.js",
"src/features/opportunities/table.js",
"src/features/opportunities/calendar.js",
"src/features/opportunities/dashboard.js",
"src/features/opportunities/filters.js",
"src/features/opportunities/formatters.js",
@@ -51,7 +61,11 @@ module.exports = {
"src/features/preferences/storage.js",
"src/features/preferences/view.js",
"src/features/products/content-jobs.js",
"src/features/products/workloads.js",
"src/features/products/details.js",
"src/features/products/ramp-projection.js",
"src/features/products/forecast-update.js",
"src/features/products/ramp-projection-view.js",
"src/features/ai-summary/content.js",
"src/features/consumption/plans-action.js",
"src/features/install-base/action.js",
@@ -75,19 +89,26 @@ module.exports = {
"src/content/application.js"
],
"background": [
"src/shared/time-dates.js",
"src/services/fusion/time-entries.js",
"src/shared/messages.js",
"src/shared/debug-body.js",
"src/background/contracts.js",
"src/features/products/cache-state.js",
"src/auth/consumer-session.js",
"src/auth/spa-credentials.js",
"src/auth/sr-request-headers.js",
"src/services/spa/accounts.js",
"src/services/spa/install-base.js",
"src/services/spa/customer-service-requests.js",
"src/services/spa/workload-actions.js",
"src/services/spa/workloads.js",
"src/features/products/workloads-cache.js",
"src/services/spa/action-options.js",
"src/services/fusion/sr-details.js",
"src/services/fusion/sr-create.js",
"src/services/spa/current-user.js",
"src/services/spa/page-client.js",
"src/services/spa/consumption.js",
"src/services/spa/response-shapes.js",
"src/services/http/background-json.js",

View File

@@ -8,9 +8,16 @@ function createAuthConsumerSession({
fetchJson,
supportsFirefoxCookieStoreTabs,
tabsCreate,
tabsUpdate,
tabsRemove,
responseHeaders,
cookieChanges,
inspectPage,
setTimeout,
clearTimeout,
wait
}) {
const sessionTabs = new Map();
async function requestConsumerTokenService(sender) {
const debugRequests = [];
@@ -150,7 +157,8 @@ function createAuthConsumerSession({
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
}
await wait(retryDelayMs);
if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs);
else await wait(retryDelayMs);
}
}
@@ -160,7 +168,7 @@ function createAuthConsumerSession({
const debugEntry = debugRequests[debugRequests.length - 1];
try {
await tabsRemove(sessionTab.id);
await closeConsumerTokenSessionTab(sessionTab.id);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json"
@@ -212,7 +220,7 @@ function createAuthConsumerSession({
}
}
function openConsumerTokenSessionTab(sender) {
async function openConsumerTokenSessionTab(sender) {
const details = {
url: CUSTOMER_TOKEN_SESSION_URL,
active: false
@@ -226,8 +234,184 @@ function createAuthConsumerSession({
details.cookieStoreId = sender.tab.cookieStoreId;
}
return tabsCreate(details);
const tab = await tabsCreate(details);
if (Number.isInteger(tab?.id)) {
const state = { failedStatus: 0, reloads: 0, lastReloadAt: -Infinity, readyVersion: 0, consumedVersion: 0, closed: false };
const signal = reason => {
state.readyVersion++;
state.readyReason = reason;
state.wake?.();
};
state.listener = details => {
if (details.tabId !== tab.id
|| !/^https:\/\/spa\.oracle\.com\//.test(details.url)) return;
if (details.type === "main_frame") {
if (details.statusCode === 401 || details.statusCode === 403) {
state.failedStatus = details.statusCode;
state.consumedVersion = state.readyVersion;
state.wake?.();
} else if (details.statusCode >= 200 && details.statusCode < 300) {
state.failedStatus = 0;
if (!details.url.startsWith("https://spa.oracle.com/cloudgate/")) signal("spa-document-response");
}
} else if (details.type === "xmlhttprequest" && details.statusCode === 200
&& details.url.split("?")[0] === "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"
&& details.responseHeaders?.some(header => header.name.toLowerCase() === "content-type" && /application\/json/i.test(header.value))) {
state.failedStatus = 0;
signal("spa-identity-json-response");
}
};
state.cookieListener = change => {
const cookie = change.cookie;
if (change.removed || !cookie?.value || !cookie.secure || !cookie.httpOnly
|| cookie.domain?.replace(/^\./, "") !== "spa.oracle.com" || !/session/i.test(cookie.name)
|| (cookie.expirationDate != null && cookie.expirationDate <= Date.now() / 1000)
|| (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId)) return;
signal("spa-session-cookie-changed");
};
try {
responseHeaders?.addListener(state.listener, { urls: ["https://spa.oracle.com/*"], types: ["main_frame", "xmlhttprequest"] }, ["responseHeaders"]);
cookieChanges?.addListener(state.cookieListener);
sessionTabs.set(tab.id, state);
} catch (error) {
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
await tabsRemove(tab.id).catch(() => {});
throw error;
}
}
return tab;
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab };
async function recoverConsumerTokenSessionTab(tab, debugEntry, reason = "") {
const state = sessionTabs.get(tab.id);
if (!state || (!state.failedStatus && !reason) || Date.now() - state.lastReloadAt < 2000) return false;
const status = state.failedStatus;
state.failedStatus = 0;
state.consumedVersion = state.readyVersion;
state.lastReloadAt = Date.now();
state.reloads++;
if (debugEntry) {
debugEntry.metadata.sessionBootstrapReloadCount = String(state.reloads);
debugEntry.metadata.sessionBootstrapReloadReason = reason || `SPA navigation returned HTTP ${status}`;
}
try {
// Restart at the entry point; OAuth callback URLs may contain single-use state.
await tabsUpdate(tab.id, { url: CUSTOMER_TOKEN_SESSION_URL, active: false });
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = "restarted-at-spa-home";
return true;
} catch (error) {
state.failedStatus = status;
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = `restart-failed: ${error.message || "unknown error"}`;
return false;
}
}
async function waitForSpaPage(tab, debugEntry) {
const startedAt = Date.now();
const deadline = startedAt + 120000;
let restartedDocument = null;
let restarts = 0;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "120000";
debugEntry.metadata.sessionWaitReason = "inspecting-spa-document";
}
while (Date.now() < deadline) {
const state = sessionTabs.get(tab.id);
if (state?.closed) break;
let page;
try { page = await inspectPage(tab); } catch { /* Cross-origin SSO pages have no SPA receiver. */ }
if (debugEntry) debugEntry.metadata.spaPageState = page?.status || "loading-or-login";
const documentId = typeof page?.documentId === "string" ? page.documentId : null;
const alreadyRead = documentId && documentId === tab.spaDocumentId;
if (state?.failedStatus || page?.status === "error" || alreadyRead) {
if (restarts < 3 && (state?.failedStatus || documentId !== restartedDocument)) {
const previousReloads = state?.reloads || 0;
const restarted = await recoverConsumerTokenSessionTab(tab, debugEntry,
state?.failedStatus ? "" : alreadyRead ? "Identity rejected in this SPA document" : "SPA document displays an authorization error");
restarts += (state?.reloads || 0) - previousReloads;
if (restarted) restartedDocument = documentId;
}
} else if (page?.status === "ready" && documentId) {
// Confirm a stable, fully loaded document, not a transient SAML page.
await wait(500);
let confirmed;
try { confirmed = await inspectPage(tab); } catch { /* A redirect started during validation. */ }
if (confirmed?.status === "ready" && confirmed.documentId === documentId && !state?.failedStatus) {
tab.spaDocumentId = documentId;
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = "spa-document-verified";
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
return;
}
}
await wait(1000);
}
const error = new Error("SPA page did not finish authentication. No further identity request was sent.");
error.code = "SPA_PAGE_NOT_READY";
throw error;
}
async function waitForConsumerTokenSession(tab, debugEntry, retryDelay) {
const state = sessionTabs.get(tab.id);
if (!state || !responseHeaders?.addListener || !setTimeout) return wait(retryDelay);
const startedAt = Date.now();
const deadline = startedAt + 20000;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "20000";
}
let restarts = 0;
let reason = "timeout-fallback";
// Do not issue API probes while SSO redirects are still establishing the session.
while (!state.closed && Date.now() < deadline) {
if (state.failedStatus) {
if (restarts >= 3) {
await wait(Math.max(0, deadline - Date.now()));
break;
}
await recoverConsumerTokenSessionTab(tab, debugEntry);
restarts++;
if (state.failedStatus) await wait(2000);
continue;
}
if (state.readyVersion > state.consumedVersion) {
await wait(250);
if (state.failedStatus) continue;
state.consumedVersion = state.readyVersion;
reason = state.readyReason;
break;
}
let timer;
try {
await new Promise(resolve => {
state.wake = resolve;
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
});
} finally {
clearTimeout?.(timer);
state.wake = null;
}
}
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = reason;
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
}
async function closeConsumerTokenSessionTab(id) {
const state = sessionTabs.get(id);
sessionTabs.delete(id);
if (state) {
state.closed = true;
state.wake?.();
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
}
await tabsRemove(id);
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab, recoverConsumerTokenSessionTab, waitForConsumerTokenSession, waitForSpaPage, closeConsumerTokenSessionTab };
}

View File

@@ -1,17 +1,32 @@
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) {
const { setTimeout } = environment;
let pending = null;
let allowed = false;
let userId = "";
function ensure() {
if (!pending) {
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
appendBackgroundRequestLog(response?.debugRequests);
const roles = response?.user?.roles;
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : "";
allowed = response?.ok === true && Array.isArray(roles)
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
return allowed;
}).catch(() => { allowed = false; return false; });
pending = (async () => {
let retryDelay = 1000;
// Keep a single pending identity request until the response is usable.
for (;;) {
try {
const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser });
appendBackgroundRequestLog(response?.debugRequests);
const user = response?.user;
if (response?.ok === true && typeof user?.id === "string" && user.id.trim()
&& typeof user.name === "string" && user.name.trim()
&& Array.isArray(user.roles) && user.roles.every(role => typeof role === "string" && role.trim())) {
userId = user.id;
allowed = !user.roles.includes("SALES_REP");
return allowed;
}
} catch (error) {
appendBackgroundRequestLog(error?.debugRequests);
}
await new Promise(resolve => setTimeout(resolve, retryDelay));
retryDelay = Math.min(retryDelay * 2, 10000);
}
})();
}
return pending;
}

52
src/auth/spa-page.js Normal file
View File

@@ -0,0 +1,52 @@
function createSpaPage() {
function start() {
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
if (!runtime) return;
const documentId = `${performance.timeOrigin}:${location.pathname}`;
let identityRequest = null;
function inspect() {
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
let status = "loading";
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
else if (document.readyState === "complete" && text
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
return { status, documentId };
}
runtime.onMessage.addListener((message, sender, reply) => {
if (sender.id !== runtime.id) return false;
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
reply(inspect());
return false;
}
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
const page = inspect();
if (page.status !== "ready" || page.documentId !== message.documentId) {
reply({ error: "SPA page is not ready or has navigated." });
return false;
}
// A document is probed at most once; failed identity reads require a fresh navigation.
if (!identityRequest) identityRequest = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
});
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
contentType: response.headers.get("content-type") || "", body: await response.text() };
} catch (error) {
return { error: error.message || "Unable to read SPA identity." };
} finally { clearTimeout(timer); }
})();
identityRequest.then(reply);
return true;
});
}
return { start };
}

View File

@@ -0,0 +1,131 @@
function createSrRequestHeaders({ runtimeApi }) {
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
const appPath = "/ic/builder/rt/SeaaS_initiation/";
const internalPath = "/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const storageKey = "opportunitiesExtension.srAppBuilderClientId.v1";
const maxAge = 60 * 60 * 1000;
let observed = null;
let started = false;
function valid(record) {
return record?.origin === origin && typeof record.value === "string"
&& /^[\x21-\x7e]{1,2048}$/.test(record.value)
&& Number.isFinite(record.observedAt) && record.observedAt <= Date.now()
&& Date.now() - record.observedAt < maxAge;
}
function observeHeaders(rawUrl, headers) {
let url;
try { url = new URL(rawUrl); } catch { return; }
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management|oalset_timeentrymobile)\/live(?:\/|;)/.test(url.pathname)) return;
// Read only the client ID, not cookies, bodies or authorization.
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
const record = { origin, value: value?.trim(), observedAt: Date.now() };
if (!valid(record)) return;
observed = record;
// Session storage survives service-worker suspension without persisting across browser sessions.
runtimeApi.storage?.session?.set({ [storageKey]: record }).catch(() => {});
}
function start() {
if (started || !runtimeApi?.webRequest) return;
started = true;
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`, `${origin}/ic/builder/rt/oalset_timeentrymobile/*`], types: ["xmlhttprequest"] };
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
}, filter, ["requestHeaders"]);
runtimeApi.webRequest.onHeadersReceived?.addListener(details => {
if (!details.incognito) observeHeaders(details.url, details.responseHeaders);
}, filter, ["responseHeaders"]);
}
async function previewTokenHeaders(url) {
const target = new URL(url);
if (target.origin !== origin || !target.pathname.startsWith(appPath) || !/\/Catalogue_c(?:\/\d+)?$/.test(target.pathname)) {
throw new Error("Unexpected Service Request token URL.");
}
return sessionHeaders(target.origin, "application/vnd.oracle.adf.action+json");
}
async function sessionHeaders(targetOrigin, contentType) {
if (!valid(observed)) {
try {
const stored = await runtimeApi.storage?.session?.get(storageKey);
if (valid(stored?.[storageKey]) && !valid(observed)) observed = stored[storageKey];
} catch { /* A missing capture must not prevent trying the other required headers. */ }
}
return {
Accept: "application/json", "Content-Type": contentType,
Authorization: "Session", Origin: targetOrigin,
...(valid(observed) ? { "x-appbuilder-client-id": observed.value } : {})
};
}
async function tokenHeaders(url, serviceRequestUrl) {
const headers = await previewTokenHeaders(url);
if (!/\/Catalogue_c\/\d+$/.test(new URL(url).pathname)) throw new Error("Missing Catalogue Id.");
const serviceTarget = new URL(serviceRequestUrl);
const isServiceRequest = serviceTarget.pathname.startsWith(appPath) && serviceTarget.pathname.endsWith("/set/seaas/mgmt/serviceRequests");
if (serviceTarget.origin !== headers.Origin || !isServiceRequest || serviceTarget.search || serviceTarget.hash) {
throw new Error("Unexpected Service Request URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the Service Request token header permissions.");
}
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
const catalogUrl = url.slice(0, url.lastIndexOf("/") + 1);
const escapedCatalogUrl = catalogUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedServiceUrl = serviceTarget.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ruleIds = [7201, 7202];
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: ruleIds,
addRules: [`^${escapedCatalogUrl}[0-9]+$`, `^${escapedServiceUrl}$`].map((regexFilter, index) => ({
id: ruleIds[index], priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
condition: { regexFilter, initiatorDomains: [extensionDomain],
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
}))
});
return headers;
}
async function internalServiceRequestHeaders(url) {
const target = new URL(url);
if (target.origin !== origin || target.pathname !== internalPath || target.search || target.hash) {
throw new Error("Unexpected Internal Service Request URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the Internal Service Request header permissions.");
}
const headers = await sessionHeaders(target.origin, "application/json");
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: [7203],
addRules: [{
id: 7203, priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [extensionDomain],
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
}]
});
return headers;
}
async function timeEntriesHeaders(url) {
const target = new URL(url);
const path = "/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries";
const isDelete = target.pathname.startsWith(`${path}/`) && /^[1-9]\d*$/.test(target.pathname.slice(path.length + 1));
if (target.origin !== origin || (target.pathname !== path && !isDelete) || target.search || target.hash) {
throw new Error("Unexpected time entries URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the time entry header permissions.");
}
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: [isDelete ? 7205 : 7204],
addRules: [{
id: isDelete ? 7205 : 7204, priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: target.origin }] },
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [new URL(runtimeApi.runtime.getURL("/")).hostname],
requestMethods: isDelete ? ["delete"] : ["post", "put"], resourceTypes: ["xmlhttprequest"] }
}]
});
return sessionHeaders(target.origin, "application/json");
}
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, timeEntriesHeaders, observeResponseHeaders: observeHeaders };
}

View File

@@ -92,10 +92,7 @@ function createAuthTokenRelay({
throw new Error("Token relay response does not contain access_token.");
}
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
logRequestSuccess(requestEntry, response, responseBody);
if (url === TOKEN_RELAY_URL) {
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
@@ -122,7 +119,7 @@ function createAuthTokenRelay({
method: "GET",
url,
headers: {
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
"x-xsrf-token": xsrfTokenSource.token || "(missing)"
},
metadata: {
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",

View File

@@ -4,12 +4,25 @@ function createBackgroundApplication(overrides = {}) {
browser: globalThis.browser,
chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
...overrides
};
const messageTypes = createMessageTypes();
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
const contracts = createBackgroundContracts();
const spaPageClient = createSpaPageClient({
sendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args),
getHttp: () => httpBackgroundJson
});
const spaCurrentUser = createSpaCurrentUser({
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),
waitForSession: (tab, entry) => authConsumerSession.waitForSpaPage(tab, entry),
closeSessionTab: id => authConsumerSession.closeConsumerTokenSessionTab(id),
sessionUrl: contracts.CUSTOMER_TOKEN_SESSION_URL
});
const requestCurrentUser = sender => spaCurrentUser.requestCurrentUser(sender);
const productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
@@ -21,7 +34,13 @@ function createBackgroundApplication(overrides = {}) {
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
responseHeaders: (environment.browser || environment.chrome)?.webRequest?.onHeadersReceived,
cookieChanges: (environment.browser || environment.chrome)?.cookies?.onChanged,
inspectPage: tab => spaPageClient.inspect(tab),
setTimeout: environment.setTimeout,
clearTimeout: environment.clearTimeout,
wait: (...args) => platformBackgroundApi.wait(...args)
});
const authSpaCredentials = createAuthSpaCredentials({
@@ -56,8 +75,8 @@ function createBackgroundApplication(overrides = {}) {
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});
const productsBackgroundJobs = createProductsBackgroundJobs({
@@ -114,6 +133,17 @@ function createBackgroundApplication(overrides = {}) {
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const workloadsCache = createWorkloadsCache({
fetchAllWorkloads: (...args) => spaWorkloads.fetchAllWorkloads(...args),
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
});
const spaWorkloads = createSpaWorkloads({
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
getCachedWorkloads: (...args) => workloadsCache.get(...args),
normalizeCacheMaxAge: value => productsCache.normalizeCacheMaxAge(value)
});
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
@@ -125,9 +155,42 @@ function createBackgroundApplication(overrides = {}) {
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
const fusionSrDetails = createFusionSrDetails({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
const fusionSrCreate = createFusionSrCreate({
runtimeApi,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
previewTokenHeaders: url => srRequestHeaders.previewTokenHeaders(url)
});
const fusionSrDetails = createFusionSrDetails({
cookieChanges: runtimeApi?.cookies?.onChanged,
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
wait: (...args) => platformBackgroundApi.wait(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
});
function start() {
startBackgroundApplication({ messageTypes, requestInternalServiceRequest: message => fusionSrDetails.requestInternalServiceRequest(message), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
srRequestHeaders.start();
startBackgroundApplication({
requestWorkloads: message => spaWorkloads.requestWorkloads(message),
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
requestTimeEntriesSave: message => timeEntries.requestTimeEntriesSave(message),
requestTimeEntriesDelete: message => timeEntries.requestTimeEntriesDelete(message),
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
requestActiveServiceRequests: message => timeEntries.requestActiveServiceRequests(message),
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),
requestPostServiceRequest: message => fusionSrCreate.requestPostServiceRequest(message), prepareSrSession: sender => fusionSrDetails.prepareSession(sender), requestSrLookupWithSession: (...args) => fusionSrDetails.requestLookupWithSession(...args), messageTypes, requestSrLookupLanguage: () => fusionSrDetails.requestSrLookupLanguage(), requestSrLookupTechPlatform: () => fusionSrDetails.requestSrLookupTechPlatform(), requestSrLookupOpportunityBusiness: message => fusionSrDetails.requestSrLookupOpportunityBusiness(message), requestSrLookupPrimaryPillar: () => fusionSrDetails.requestSrLookupPrimaryPillar(), requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
@@ -142,6 +205,8 @@ function createBackgroundApplication(overrides = {}) {
httpBackgroundJson,
productsBackgroundJobs,
productsCache,
workloadsCache,
spaWorkloads,
fusionProducts,
authXsrfRecovery,
authXsrfCookies,

View File

@@ -16,7 +16,7 @@ function createBackgroundContracts() {
const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService";
const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html";
const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/";
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;

View File

@@ -1,17 +1,39 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
requestWeekDetails,
requestTimeEntries,
requestTimeEntriesSave,
requestTimeEntriesDelete,
requestLookupTaskType,
requestServiceRequestExpand,
requestResourceUser,
requestActiveServiceRequests,
invalidateProductsCache,
invalidateResourceUser = async () => {},
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestWorkloads,
requestWorkloadForecastUpdate,
invalidateWorkloadsCache,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestPostServiceRequest,
requestPostInternalServiceRequest,
requestInternalServiceRequest,
requestSrLookupPrimaryPillar,
requestSrLookupWithSession,
prepareSrSession,
requestSrLookupLanguage,
requestSrLookupTechPlatform,
requestLookupTypePlatform,
requestInternalSrServices,
requestSrLookupOpportunityBusiness,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
@@ -24,9 +46,58 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === messageTypes.requestLookupTaskType) {
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestTimeEntriesSave) {
requestTimeEntriesSave(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestTimeEntriesDelete) {
requestTimeEntriesDelete(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if ([messageTypes.requestTimeEntries, messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
const request = message.type === messageTypes.requestTimeEntries ? requestTimeEntries : message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestPostInternalServiceRequest) {
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestPostServiceRequest) {
requestPostServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.prepareSrSession) {
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
const srLookups = {
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
};
if (Object.hasOwn(srLookups, message.type)) {
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.invalidateProductsCache) {
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
Promise.all([invalidateProductsCache(), invalidateWorkloadsCache()]).then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
return true;
}
if (message.type === messageTypes.getXsrfToken) {
@@ -99,7 +170,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
if (message.type === messageTypes.requestCurrentUser) {
requestCurrentUser().then(sendResponse).catch(error => sendResponse({
requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load current user.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
@@ -108,13 +179,22 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
request(message).then(sendResponse).catch(error => sendResponse({
request(message, sender).then(sendResponse).catch(error => sendResponse({
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if ([messageTypes.requestWorkloads, messageTypes.requestWorkloadForecastUpdate].includes(message.type)) {
const request = message.type === messageTypes.requestWorkloads ? requestWorkloads : requestWorkloadForecastUpdate;
request(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workloads.", httpStatus: error.httpStatus,
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestWlActions) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",

View File

@@ -19,11 +19,13 @@ function createContentApplication(overrides = {}) {
};
const messageTypes = createMessageTypes();
const currentUser = createCurrentUser({
environment,
messageTypes,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
});
const contracts = createContentContracts();
const searchableSelect = createSearchableSelect({ environment });
const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState();
@@ -55,7 +57,10 @@ function createContentApplication(overrides = {}) {
environment
});
const contentModal = createContentModal({
closeActionForm: (...args) => workloadActionForm.close(...args),
createManageTimeButton: () => manageTime.createButton(),
prepareSrSession: () => srSessionPreparation.ensure(),
closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
mountOpportunityViews: (...args) => opportunityCalendar.mount(...args),
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
closeCrmSrPopover: () => crmSrPopover.close(),
ensureCurrentUser: () => currentUser.ensure(),
@@ -132,6 +137,58 @@ function createContentApplication(overrides = {}) {
environment
});
const svgPaths = createSvgPaths({ environment });
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, searchableSelect, dates: createTimeDates(),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
async requestDelete(id) {
await srSessionPreparation.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesDelete, id });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to delete time entry.");
return result.result;
},
async requestSave(method, items) {
await srSessionPreparation.ensure();
await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesSave, method, items, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to save time entries.");
return result.result;
},
async requestEntries(weekId) {
await srSessionPreparation.ensure();
await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntries, weekId, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load time entries.");
return result.items;
},
async requestTaskTypes() {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Task Types.");
return result.items;
},
async requestExpand(srNumbers) {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestServiceRequestExpand, srNumbers });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load activities.");
return result.items;
},
async requestServices(weekId) {
await srSessionPreparation.ensure();
await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestActiveServiceRequests, weekId, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Requests.");
return result.items;
},
async requestWeek(dateReference) {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestWeekDetails, dateReference });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load week details.");
return result.items;
}
});
const contentStyles = createContentStyles({
STYLE_ID: contracts.STYLE_ID,
redwoodStyles,
@@ -170,12 +227,14 @@ function createContentApplication(overrides = {}) {
environment
});
const platformContentMessages = createPlatformContentMessages({
requestCache: createFormRequestCache({ getHours: () => preferencesState.opptyProductsCacheHours, getUserId: () => currentUser.getUserId() }),
messageTypes,
environment
});
const opportunitiesController = createOpportunitiesController({
ensureCurrentUser: () => currentUser.ensure(),
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
authSessionState,
@@ -216,19 +275,67 @@ function createContentApplication(overrides = {}) {
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
maskToken: (...args) => debugContentLog.maskToken(...args), environment
});
const crmSrPopover = createCrmSrPopover({
requestDetails: async srId => {
const internalSrSession = createInternalSrSession({
environment,
query: async srId => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load SR details.");
return result.items;
},
return result;
}
});
const crmSrPopover = createCrmSrPopover({
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestDetails: srId => internalSrSession.request(srId),
MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
});
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
async function requestSrFormLookup(type, fields = {}) {
const result = await platformContentMessages.sendRuntimeMessage({ type, ...fields });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Request options.");
return result.items;
}
const internalSrForm = createInternalSrForm({
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
requestSave: async payload => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostInternalServiceRequest, payload, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to create Internal Service Request.");
return result.result;
},
getOpportunity: number => number == null ? null : opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
MODAL_ID: contracts.MODAL_ID, environment, searchableSelect,
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestPlatform: () => requestSrFormLookup(messageTypes.requestLookupTypePlatform),
requestServices: parentServiceId => requestSrFormLookup(messageTypes.requestInternalSrServices, { parentServiceId, emailAddress: currentUser.getUserId() })
});
const crmSrForm = createCrmSrForm({
requestSave: async fields => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostServiceRequest, ...fields, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to save Service Request.");
return result;
},
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestLanguage: () => requestSrFormLookup(messageTypes.requestSrLookupLanguage),
requestPlatform: () => requestSrFormLookup(messageTypes.requestSrLookupTechPlatform),
requestServices: () => requestSrFormLookup(messageTypes.requestSrLookupOpportunityBusiness, { emailAddress: currentUser.getUserId() }),
searchableSelect,
MODAL_ID: contracts.MODAL_ID, environment,
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
isMissing: number => opportunityCrmServiceRequests.getSummary(number)?.state === "missing",
createCloseIcon: () => contentIcons.createCloseIcon(),
requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar)
});
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
createSrButton: number => crmSrForm.createButton(number),
closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); },
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
activityCache,
reportProgress: (...args) => activityProgress.update("sr", ...args),
@@ -238,7 +345,25 @@ function createContentApplication(overrides = {}) {
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
});
const opportunityCalendar = createOpportunityCalendar({
createViewIcon: view => contentIcons.createViewIcon(view),
environment, loadLibrary: () => loadOpportunityCalendarLibrary(),
getState: () => opportunitiesTableState.opportunitiesTableState,
getItems: () => opportunitiesFilters.getSortedOpportunities(),
getVersion: () => opportunitiesQueryState.periodRequestVersion,
formatRevenue: item => opportunitiesFormatters.formatRevenue(item),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
getOwnerName: item => opportunitiesFormatters.getOwnerName(item),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: () => contentIcons.createCloseIcon(),
createStageBadge: value => opportunitiesLinks.createStageBadge(value),
createStatusBadge: value => opportunitiesLinks.createStatusBadge(value),
opportunityUrl: contracts.OPPORTUNITY_DETAIL_URL, modalId: contracts.MODAL_ID,
closeSrPopover: () => crmSrPopover.close()
});
const opportunitiesTable = createOpportunitiesTable({
renderCalendar: (...args) => opportunityCalendar.render(...args),
renderActivityFilters: () => activityFilters.render(),
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
@@ -253,6 +378,7 @@ function createContentApplication(overrides = {}) {
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
createProjectionIndicator: (...args) => rampProjectionView.createOpportunityIndicator(...args),
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
@@ -352,6 +478,8 @@ function createContentApplication(overrides = {}) {
resetRelatedData: () => {
opportunityCrmServiceRequests.reset();
opportunityWorkloadActions.reset();
productWorkloads.reset();
rampProjectionView.close(false);
consumptionPlansView.closeAccountPlansModal();
installBaseView.closeInstallBaseModal();
serviceRequestsView.closeServiceRequestsModal();
@@ -364,6 +492,18 @@ function createContentApplication(overrides = {}) {
}
});
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
const srSessionPreparation = createSrSessionPreparation({
async onReady() {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load resource user.");
},
ensureCurrentUser: () => currentUser.ensure(),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }),
reportProgress: state => { activityProgress.update("session", state); manageTime.setReady(state === "complete"); },
appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries)
});
const activityFilters = createActivityFilters({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
@@ -383,6 +523,8 @@ function createContentApplication(overrides = {}) {
}
});
const productsContentJobs = createProductsContentJobs({
loadWorkloads: (...args) => productWorkloads.load(...args),
joinWorkloads: number => productWorkloads.joinProducts(number),
beginProductActivities: (...args) => productActivities.begin(...args),
completeProductActivities: (...args) => productActivities.complete(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
@@ -396,6 +538,14 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const productWorkloads = createProductWorkloads({
productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
requestConsumerTokenServiceOnce: () => authContentSession.requestConsumerTokenServiceOnce(),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
getPrincipal: () => currentUser.getUserId() || authSessionState.principal,
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
});
const workloadActionOptions = createWorkloadActionOptions({
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
@@ -403,6 +553,7 @@ function createContentApplication(overrides = {}) {
messageTypes
});
const workloadActionForm = createWorkloadActionForm({
searchableSelect,
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
saveAction: (...args) => workloadActionOptions.save(...args),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
@@ -433,6 +584,7 @@ function createContentApplication(overrides = {}) {
productsContentState, messageTypes, environment
});
const productsDetails = createProductsDetails({
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
@@ -442,6 +594,30 @@ function createContentApplication(overrides = {}) {
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
environment
});
const rampProjection = createRampProjection();
const workloadForecastUpdate = createWorkloadForecastUpdate({
compare: product => rampProjection.compare(product),
ensureCurrentUser: () => currentUser.ensure(),
getCurrentUserId: () => currentUser.getUserId(),
requestAuthorization: () => authContentSession.requestConsumerTokenServiceOnce(),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
messageTypes,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
productsContentState,
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
});
const rampProjectionView = createRampProjectionView({
saveEstimates: product => workloadForecastUpdate.save(product),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
MODAL_ID: contracts.MODAL_ID,
compare: product => rampProjection.compare(product),
expandProducts: number => productsDetails.expandProducts(number),
createAlertIcon: () => contentIcons.createAlertIcon(),
createCloseIcon: () => contentIcons.createCloseIcon(),
formatAmount: (...args) => productsDetails.formatProductAmount(...args),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
environment
});
const aiSummaryContent = createAiSummaryContent({
aiSummaryState,
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
@@ -579,7 +755,6 @@ function createContentApplication(overrides = {}) {
});
const debugContentLog = createDebugContentLog({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
});
@@ -597,6 +772,7 @@ function createContentApplication(overrides = {}) {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
}
return {
opportunityCalendar,
workloadActionForm,
activityFilters,
start, contracts,
@@ -636,11 +812,16 @@ function createContentApplication(overrides = {}) {
preferencesStorage,
preferencesView,
productsContentJobs,
productWorkloads,
productActivities,
activityProgress,
srSessionPreparation,
refreshAll,
activityCache,
productsDetails,
rampProjection,
workloadForecastUpdate,
rampProjectionView,
aiSummaryContent,
consumptionPlansAction,
installBaseAction,

View File

@@ -89,8 +89,7 @@ function createContentContracts() {
"Next Quarter",
"Previous Quarter",
"Current Fiscal Year",
"4 Rolling Quarters (CQ + 3)",
"RENEWALS (Current + Past Due)"
"4 Rolling Quarters (CQ + 3)"
];
const ALLOWED_PATHS = [
"/hcmUI/faces/FuseWelcome",

View File

@@ -85,5 +85,67 @@ function createContentIcons({
svg.append(path);
return svg;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon };
function createViewIcon(view) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const calendarFrame = "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z";
path.setAttribute("d", view === "today" ? `${calendarFrame}M9 16l2 2 4-4`
: view === "week" ? `${calendarFrame}M6 14h12v4H6Z M10 14v4m4-4v4`
: view === "calendar"
? "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM7 14h2m6 0h2m-10 4h2m6 0h2"
: "M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1ZM3 9h18M9 9v12M3 15h18");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.8");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.append(path);
return svg;
}
function createAlertIcon() {
/* Lucide triangle-alert (https://lucide.dev/icons/triangle-alert), ISC License.
* Copyright (c) 2026 Lucide Icons and Contributors
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*
* Derived from Feather alert-triangle, MIT License.
* Copyright (c) 2013-present Cole Bemis
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
for (const [name, value] of Object.entries({ viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false",
fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" })) svg.setAttribute(name, value);
for (const d of ["m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3", "M12 9v4", "M12 17h.01"]) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon, createAlertIcon };
}

View File

@@ -34,7 +34,9 @@ function createContentModal({
closePreferencesModal,
createSettingsIcon,
createRefreshAllButton,
createManageTimeButton,
createActivityFilterControls,
mountOpportunityViews,
debugState,
ensureExtensionStyles,
getCustomerOptions,
@@ -57,6 +59,7 @@ function createContentModal({
closeCrmSrPopover,
requestTokenRelayOnce,
ensureCurrentUser,
prepareSrSession,
resetCustomerAndOwnerFiltersForPeriod,
resetOpportunitiesTable,
restoreFilterPreferences,
@@ -83,6 +86,7 @@ function createContentModal({
if (existingModal) {
existingModal.hidden = false;
prepareSrSession?.();
existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
@@ -173,7 +177,7 @@ function createContentModal({
const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
headerActions.append(themeButton, createRefreshAllButton(() => select.value), createManageTimeButton(), preferencesButton, closeButton);
header.append(titleBlock, headerActions);
@@ -401,7 +405,7 @@ function createContentModal({
opportunitiesTable.setAttribute("aria-label", "Opportunities");
tableSurface.append(opportunitiesTable);
body.append(tableSurface);
mountOpportunityViews(body, tableSurface);
const debugPanel = document.createElement("aside");
debugPanel.id = DEBUG_PANEL_ID;
@@ -420,6 +424,7 @@ function createContentModal({
renderOwnerFilter();
renderOpportunitiesTable();
requestOpportunitiesForPeriod(select.value);
prepareSrSession?.();
select.focus();
}

View File

@@ -1,6 +1,5 @@
function createDebugContentLog({
previewDebugBody,
DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel
}) {
@@ -99,7 +98,7 @@ function createDebugContentLog({
}
function createDebugBody(text) {
return previewDebugBody(text, DEBUG_BODY_LIMIT);
return previewDebugBody(text);
}
function headersToObject(headers) {
@@ -126,7 +125,7 @@ function createDebugContentLog({
.join("\n");
}
function formatDebugBody(body) {
function formatDebugBody(body, requestName = "") {
const text = body || "(none)";
try {
const normalized = String(text).replace(/^\uFEFF/, "").trimStart()
@@ -137,8 +136,14 @@ function createDebugContentLog({
for (let depth = 0; depth < 2 && typeof value === "string" && /^[\s]*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
if (!debugState.debugSecretsVisible && requestName === "requestSrTokenCatalog") {
value = value && typeof value === "object" && !Array.isArray(value)
? { ...value, ...(Object.hasOwn(value, "result") ? { result: formatHiddenDebugValue(value.result) } : {}) }
: formatHiddenDebugValue(value);
}
return JSON.stringify(debugState.debugSecretsVisible ? value : maskSensitiveDebugValue(value), null, 2);
} catch (error) {
if (!debugState.debugSecretsVisible && /^(requestSrTokenCatalog|requestCurrentUser|requestTokenRelay.*)$/.test(requestName)) return formatHiddenDebugValue(text);
return text;
}
}
@@ -162,7 +167,7 @@ function createDebugContentLog({
}
function isSensitiveDebugKey(key) {
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key);
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$|x-appbuilder-client-id/i.test(key);
}
function formatHiddenDebugValue(value) {

View File

@@ -26,7 +26,8 @@ function createDebugContentView({
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
if (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other";
}
@@ -249,7 +250,7 @@ function createDebugContentView({
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody, entry.name));
item.append(heading, details);
list.append(item);

View File

@@ -0,0 +1,64 @@
.oe-time-dialog { --time-bg: #fff; --time-input-bg: #fff; --time-muted: #faf9f8; --time-border: #dedbd7; --time-input-border: #8f8983; --time-accent: #00758f; width: min(1600px, 100%); max-height: calc(100dvh - 24px); display: flex; flex-direction: column; overflow: hidden; font-size: 13px; line-height: 20px; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-dialog { --time-bg: #292725; --time-input-bg: #211f1d; --time-muted: #35322f; --time-border: #514c47; --time-input-border: #918a82; --time-accent: #80d6e8; }
.oe-time-modal { grid-template-columns: minmax(0, 1fr); }
.oe-time-dialog, .oe-time-body { min-width: 0; }
.oe-time-dialog * { box-sizing: border-box; }
.oe-time-dialog [hidden] { display: none !important; }
.oe-time-header { flex-shrink: 0; }
.oe-time-body { padding: 18px; overflow: auto; min-height: 0; }
.oe-time-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.oe-time-navigation { display: flex; align-items: center; gap: 8px; }
.oe-time-ending { display: grid; gap: 4px; text-align: right; }
.oe-time-summary { display: grid; grid-template-columns: repeat(4, 1fr); padding: 12px 0; gap: 12px; text-align: center; border-block: 1px solid var(--time-border); margin-bottom: 18px; }
.oe-time-body button { min-width: 38px; min-height: 38px; display: inline-flex; gap: 8px; align-items: center; justify-content: center; color: inherit; background: transparent; border: 1px solid var(--time-input-border); border-radius: 4px; padding: 7px; cursor: pointer; font: inherit; }
.oe-time-dialog button:hover:not(:disabled) { background: var(--time-muted); border-color: var(--time-accent); }
.oe-time-dialog svg { width: 17px; height: 17px; flex-shrink: 0; }
.oe-time-dialog input, .oe-time-dialog select, .oe-time-dialog textarea { font: inherit; line-height: 20px; color: inherit; background: var(--time-input-bg); border: 1px solid var(--time-input-border); border-radius: 4px; padding: 8px; min-height: 38px; }
.oe-time-dialog select { height: 38px; }
.oe-time-dialog :focus-visible { outline: 2px solid var(--time-accent); outline-offset: 2px; }
.oe-time-dialog :disabled { opacity: .55; cursor: not-allowed; }
.oe-time-grid { overflow-x: auto; }
.oe-time-grid table { width: 100%; min-width: 1060px; border-collapse: collapse; table-layout: fixed; }
.oe-time-grid th { background: var(--time-muted); padding: 10px 5px; font-weight: 600; text-align: center; border-bottom: 1px solid var(--time-border); }
.oe-time-grid th:first-child { width: 23%; }
.oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; }
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; }
.oe-time-grid td { padding: 10px 5px; border-bottom: 1px solid var(--time-border); vertical-align: middle; text-align: center; }
.oe-time-duplicate-row { outline: 2px solid #b3261e; outline-offset: -2px; animation: oe-time-duplicate-fade 5.5s linear forwards; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-duplicate-row { --time-duplicate-color: #ffaaa4; }
@keyframes oe-time-duplicate-fade { 0%, 90.909% { outline-color: var(--time-duplicate-color, #b3261e); } 100% { outline-color: transparent; } }
@media (prefers-reduced-motion: reduce) { .oe-time-duplicate-row { animation-timing-function: step-end; } }
.oe-time-grid select { width: 100%; }
.oe-time-grid input[type="number"] { width: min(100%, 60px); padding: 6px 3px; text-align: center; }
.oe-time-grid .opportunities-extension-action-combobox input { width: 100%; text-align: left; text-overflow: ellipsis; }
.oe-time-grid .opportunities-extension-action-options { text-align: left; }
.oe-time-dialog .opportunities-extension-select-tooltip { position: fixed; z-index: 100; width: min(560px, calc(100vw - 40px)); max-height: min(440px, calc(100dvh - 48px)); overflow: auto; padding: 14px; border: 1px solid var(--time-input-border); border-radius: 4px; background: var(--time-input-bg); color: inherit; box-shadow: 0 4px 16px #0003; text-align: left; }
.oe-time-sr-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 18px; margin: 0; }
.oe-time-sr-details > div { min-width: 0; overflow-wrap: anywhere; }
.oe-time-sr-details dt { font-size: 11px; opacity: .75; margin-bottom: 2px; }
.oe-time-sr-details dd { margin: 0; white-space: pre-wrap; }
.oe-time-sr-details .oe-time-sr-description { grid-column: 1 / -1; }
.oe-time-day > span { display: block; }
.oe-time-day .oe-time-day-label { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 9px; }
.oe-time-day-total { font-size: 10px; background: var(--time-accent); color: var(--time-bg); min-width: 16px; border-radius: 8px; padding: 2px 4px; }
.oe-time-notes textarea { width: 100%; resize: vertical; }
.oe-time-notes-box { position: relative; text-align: left; background: var(--time-input-bg); border: 1px solid var(--time-input-border); border-radius: 4px; padding: 14px 12px 42px; }
.oe-time-notes-count { display: block; font-weight: 600; opacity: .8; margin-bottom: 8px; }
.oe-time-notes-box textarea { border: 0; min-height: 128px; padding: 0; }
.oe-time-notes-box .oe-time-notes-collapse { position: absolute; right: 0; bottom: 0; background: var(--time-muted); }
.oe-time-footer { flex-wrap: wrap; justify-content: flex-start; margin-top: 18px; }
.oe-time-footer button { padding: 7px 14px; }
.oe-time-status { padding: 12px 0; }
.oe-time-loading-status { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.oe-time-skeleton { display: inline-block; vertical-align: middle; border-radius: 4px; background: var(--time-border); color: transparent; animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
.oe-time-skeleton-select { display: block; width: 100%; height: 38px; }
.oe-time-skeleton-control { width: min(100%, 60px); height: 38px; }
.oe-time-skeleton-heading { width: 65%; height: 20px; margin: 17px 0; }
.oe-time-skeleton-summary { min-width: 44px; height: 20px; }
@keyframes oe-time-pulse { from { opacity: .4; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .oe-time-skeleton { animation: none; } }
.oe-time-status[role="alert"] { color: #a12d27; }
.oe-time-dialog [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-status[role="alert"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-dialog [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
@media (max-width: 700px) { .oe-time-modal { padding: 8px; } .oe-time-dialog { max-height: calc(100dvh - 16px); } .oe-time-body { padding: 12px; } .oe-time-summary { grid-template-columns: repeat(2, 1fr); text-align: left; } .oe-time-toolbar { flex-wrap: wrap; } .oe-time-ending { text-align: left; } }

View File

@@ -0,0 +1,525 @@
function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
const { document } = environment;
let active = null;
let ready = false;
let launcher = null;
const referenceDateKey = "opportunities-extension-manage-time-reference-date";
let lastReferenceDate = "";
function savedReferenceDate() {
try {
const saved = environment.localStorage?.getItem(referenceDateKey);
if (dates.parse(saved)) lastReferenceDate = saved;
} catch { /* Keep the in-memory selection when storage is unavailable. */ }
return dates.parse(lastReferenceDate) ? lastReferenceDate : dates.today();
}
function saveReferenceDate(value) {
if (!dates.parse(value)) return;
lastReferenceDate = value;
try { environment.localStorage?.setItem(referenceDateKey, value); }
catch { /* Keep the selection for this page even if persistence is blocked. */ }
}
function setReady(value) {
ready = !!value;
if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
}
function node(tag, className = "", text) {
const el = document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function icon(name) {
const paths = {
clock: "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM12 6v6l4 2",
left: "m15 18-6-6 6-6", right: "m9 18 6-6-6-6", close: "m6 6 12 12M18 6 6 18",
up: "m6 15 6-6 6 6",
add: "M12 5v14M5 12h14", copy: "M9 9h12v12H9ZM5 15H3V3h12v2",
notes: "M4 3h16v18H4ZM8 7h8M8 11h8M8 15h5",
trash: "M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7",
save: "M5 3h12l4 4v14H3V3ZM7 3v6h10V3M7 21v-8h10v8"
};
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", paths[name]);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.8");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.append(path);
return svg;
}
function button(label, symbol, onClick, text = false) {
const el = node("button");
el.type = "button"; el.title = label;
el.setAttribute("aria-label", label);
el.append(icon(symbol));
if (text) el.append(node("span", "", label));
if (onClick) el.addEventListener("click", onClick);
return el;
}
function createButton() {
const trigger = button("Manage Time", "clock", () => open(trigger));
trigger.className = "opportunities-extension-icon-button";
launcher = trigger; setReady(ready);
return trigger;
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;
const previous = active; active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function open(trigger) {
if (active?.saving) return;
if (!ready) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal oe-time-modal");
modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "oe-time-title");
const dialog = node("div", "opportunities-extension-action-form-dialog oe-time-dialog");
const header = node("header", "opportunities-extension-account-plans-header oe-time-header");
const title = node("h2", "", "Manage Time"); title.id = "oe-time-title";
const closeButton = button("Close Manage Time", "close", () => close());
closeButton.className = "opportunities-extension-icon-button";
header.append(title, closeButton);
const body = node("div", "oe-time-body");
const toolbar = node("div", "oe-time-toolbar");
const navigation = node("div", "oe-time-navigation");
const date = node("input"); date.type = "date"; date.value = savedReferenceDate(); date.required = true;
date.setAttribute("aria-label", "Reference date");
const ending = node("div", "oe-time-ending");
const endingValue = node("strong", "", "--");
ending.append(node("span", "", "Week Ending"), endingValue);
const changeDate = () => { saveReferenceDate(date.value); load(); };
const changeWeek = days => { if (dates.parse(date.value)) { date.value = dates.shift(date.value, days); changeDate(); } };
navigation.append(button("Previous week", "left", () => changeWeek(-7)), date, button("Next week", "right", () => changeWeek(7)));
date.addEventListener("change", changeDate); toolbar.append(navigation, ending);
const summary = node("div", "oe-time-summary"), sums = {};
for (const label of ["Service", "Non Service", "Time Off", "Total Hours"]) {
const item = node("div"); sums[label] = node("strong", "", "0h");
item.append(node("span", "", `${label}: `), sums[label]); summary.append(item);
}
const status = node("div", "oe-time-status"); status.setAttribute("role", "status");
const retry = button("Retry", "clock", load, true); retry.hidden = true;
const serviceStatus = node("div", "oe-time-status"); serviceStatus.setAttribute("role", "status"); serviceStatus.hidden = true;
const serviceRetry = button("Retry Service Requests", "clock", () => loadServices(entry.initialWeekId, true), true); serviceRetry.hidden = true;
const taskStatus = node("div", "oe-time-status"); taskStatus.setAttribute("role", "status");
const taskRetry = button("Retry Task Types", "clock", loadTaskTypes, true); taskRetry.hidden = true;
const scroll = node("div", "oe-time-grid"), table = node("table");
table.setAttribute("aria-label", "Weekly time entries"); scroll.append(table);
const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer");
const add = button("Add", "add", () => addRow("Service"), true);
const addNonService = button("Add (Non service)", "add", () => addRow("Non Service"), true);
const save = button("Save", "save", saveEntries, true); save.disabled = true;
const saveStatus = node("div", "oe-time-status"); saveStatus.hidden = true; saveStatus.setAttribute("role", "status");
footer.append(add, addNonService, save);
body.append(toolbar, summary, status, retry, serviceStatus, serviceRetry, taskStatus, taskRetry, scroll, saveStatus, footer);
dialog.append(header, body); modal.append(dialog); host.append(modal);
const entry = { modal, trigger, version: 0, days: [], rows: [], drafts: new Map(), tbody: null, dayTotals: [], services: [], servicesStarted: false, servicesReady: false, expanded: new Map() };
active = entry;
entry.deletions = new Map();
entry.taskTypes = [];
entry.taskTypesReady = false;
let combos = [];
modal.addEventListener("scroll", event => {
if (event.target === body || event.target === scroll) combos.forEach(combo => combo.sync());
}, true);
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key !== "Tab") return;
const elements = [...dialog.querySelectorAll("button:not(:disabled), input:not(:disabled), textarea:not(:disabled)")].filter(el => !el.hidden && el.getClientRects().length);
const first = elements[0], last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
});
function blankRow(type) { return { type, hours: {}, invalidHours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
const hourFields = ["sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours", "saturdayTaskHours"];
function rowPayload(row) {
const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber,
activityCode: row.activity, taskType: row.taskType, deliveryType: row.deliveryType || "",
notes: row.notes, weekId: entry.days[0]?.WeekId, tag: row.type === "Non Service" ? "NON_SERVICE" : "SERVICE" };
for (const day of entry.days) payload[hourFields[dates.fromApi(day.Date).getUTCDay()]] = Number(row.hours[day.Date]) || 0;
return payload;
}
function signature(row) { return JSON.stringify(rowPayload(row)); }
function touched(row) { return row.id || row.persisted || row.srNumber || row.activity || row.taskType || row.notes.trim() || Object.values(row.hours).some(value => value !== "") || Object.values(row.invalidHours).some(Boolean); }
function validRow(row) {
return !!row.taskType && (row.type === "Non Service" || (!!row.srNumber && !!row.activity))
&& !Object.values(row.invalidHours).some(Boolean)
&& Object.values(row.hours).every(value => Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 24)
&& Object.values(row.hours).some(value => Number(value) > 0);
}
function changedRows() { return entry.rows.filter(row => touched(row) && signature(row) !== row.baseline); }
function pendingDeletes() {
const key = entry.days.map(day => day.Date).join("|");
if (!entry.deletions.has(key)) entry.deletions.set(key, new Set());
return entry.deletions.get(key);
}
function updateSave() {
const rows = changedRows();
const deletions = pendingDeletes();
save.disabled = (rows.length > 0 && !requestSave) || (deletions.size > 0 && !requestDelete) || !entry.tbody || entry.saving || (!rows.length && !deletions.size)
|| entry.rows.some(row => touched(row) && !validRow(row)) || rows.some(row => row.persisted && !row.id);
save.title = save.disabled ? "Complete the required fields and enter hours to save changes." : "Save time entries";
}
async function saveEntries() {
updateSave();
if (save.disabled) return;
const rows = changedRows(), knownIds = new Set(entry.rows.filter(row => row.id).map(row => String(row.id)));
const controls = [];
function lock(node) {
if (["BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(String(node.tagName || node.tag).toUpperCase())) { controls.push([node, node.disabled]); node.disabled = true; }
Array.from(node.children).forEach(lock);
}
entry.saving = true; lock(modal);
saveStatus.hidden = false; saveStatus.setAttribute("role", "status"); saveStatus.textContent = "Saving time entries...";
let savedCount = 0, deletedCount = 0;
try {
const deletions = pendingDeletes();
for (const id of [...deletions]) {
await requestDelete(id);
deletions.delete(id);
deletedCount++;
}
for (const method of ["PUT", "POST"]) {
const group = rows.filter(row => method === "PUT" ? !!row.id : !row.id);
if (!group.length) continue;
const result = await requestSave(method, group.map(row => ({ ...rowPayload(row), ...(row.id ? { id: row.id } : {}) })));
group.forEach(row => { row.persisted = true; row.baseline = signature(row); });
savedCount += group.length;
if (method === "POST") {
let records = Array.isArray(result) ? result : Array.isArray(result?.items) ? result.items : [];
function assignIds() {
const candidates = rowsFromEntries(records, entry.days);
for (const row of group.filter(row => !row.id)) {
const match = candidates.find(candidate => candidate.id && !knownIds.has(String(candidate.id)) && signature(candidate) === row.baseline);
if (match) { row.id = match.id; knownIds.add(String(match.id)); }
}
}
assignIds();
if (group.some(row => !row.id)) { records = await requestEntries(entry.days[0].WeekId); assignIds(); }
if (group.some(row => !row.id)) throw new Error("Entries saved, but their IDs could not be confirmed. Reopen Manage Time before editing these entries.");
}
}
saveStatus.textContent = [savedCount ? `${savedCount} time ${savedCount === 1 ? "entry" : "entries"} saved.` : "", deletedCount ? `${deletedCount} time ${deletedCount === 1 ? "entry" : "entries"} deleted.` : ""].filter(Boolean).join(" ");
notify(saveStatus.textContent);
saveStatus.hidden = true;
} catch (error) {
saveStatus.setAttribute("role", "alert");
saveStatus.textContent = `${savedCount ? `${savedCount} saved. ` : ""}${deletedCount ? `${deletedCount} deleted. ` : ""}${error.message || "Unable to save time entries."}`;
notify(saveStatus.textContent, true);
} finally {
entry.saving = false;
controls.forEach(([control, disabled]) => { control.disabled = disabled; });
if (active === entry) { renderRows(); updateSave(); }
}
}
function total(value) { return Number(Number(value).toFixed(2)).toString(); }
function updateTotals() {
const groups = { Service: 0, "Non Service": 0, "Time Off": 0, "Total Hours": 0 };
entry.days.forEach((day, i) => {
let sum = 0;
entry.rows.forEach(row => {
const hours = Number(row.hours[day.Date]) || 0;
sum += hours; groups[row.type] += hours; groups["Total Hours"] += hours;
});
entry.dayTotals[i].textContent = total(sum);
});
Object.entries(groups).forEach(([label, value]) => { sums[label].textContent = `${total(value)}h`; });
updateSave();
}
function addRow(type, source) {
if (!entry.tbody || add.disabled) return;
entry.rows.push(source ? { ...source, id: undefined, persisted: false, baseline: undefined, duplicateHighlightAt: undefined,
...(source.type === "Service" ? { srNumber: "", activity: "" } : {}),
invalidHours: { ...source.invalidHours }, hours: { ...source.hours } } : blankRow(type)); renderRows();
}
function renderRows() {
if (entry.saving) return;
const focusedId = document.activeElement?.id;
combos = [];
entry.tbody.replaceChildren();
entry.rows.forEach((row, index) => {
const tr = node("tr");
if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) {
tr.className = "oe-time-duplicate-row";
tr.setAttribute("style", `animation-delay: -${Math.max(0, Date.now() - row.duplicateHighlightAt)}ms`);
tr.addEventListener("animationend", () => { tr.className = ""; row.duplicateHighlightAt = undefined; });
}
for (const label of [row.type === "Service" ? "Service Request" : "Non Service", "Activity", "Task Type"]) {
const td = node("td"), select = node("select");
select.id = `oe-time-${index}-${label.toLowerCase().replaceAll(" ", "-")}`;
let change = () => {};
select.setAttribute("aria-label", `${label}, row ${index + 1}`);
const placeholder = node("option", "", label); placeholder.value = ""; select.append(placeholder);
if (label === "Service Request") {
for (const sr of entry.services) {
const option = node("option", "", sr.srTitle ? `${sr.srTitle} (${sr.srNumber})` : sr.srNumber);
option.value = sr.srNumber; option.title = option.textContent; select.append(option);
}
if (row.srNumber && !entry.services.some(sr => sr.srNumber === row.srNumber)) {
const option = node("option", "", row.srNumber); option.value = row.srNumber; select.append(option);
}
select.value = row.srNumber;
select.disabled = !entry.servicesReady;
select.title = entry.servicesReady ? "Service Request" : "Loading Service Requests";
change = () => {
const duplicate = select.value && entry.rows.find(other => other !== row && other.type === "Service" && other.srNumber === select.value);
if (duplicate) {
duplicate.duplicateHighlightAt = Date.now();
select.value = row.srNumber;
notify("This Service Request is already selected in another row.", true);
renderRows(); return;
}
row.srNumber = select.value; row.activity = ""; renderRows();
};
} else if (label === "Activity") {
const source = entry.expanded.get(row.type === "Service" ? row.srNumber : "non-service-sr");
const raw = String(source?.SRActivities_c || "");
const activities = [...new Set(raw.split(raw.includes(";") ? ";" : ",").map(value => value.trim()).filter(Boolean))];
if (row.activity && !activities.includes(row.activity)) activities.push(row.activity);
for (const activity of activities) { const option = node("option", "", activity); option.value = activity; select.append(option); }
select.value = row.activity; select.disabled = !activities.length;
select.title = activities.length ? "Activity" : "No activities available for this Service Request";
change = () => { row.activity = select.value; updateSave(); };
} else if (label === "Task Type") {
const tag = row.type === "Service" ? "SERVICE" : "NON_SERVICE";
const taskTypes = entry.taskTypes.filter(item => item.tags_c === tag);
for (const item of taskTypes) {
const option = node("option", "", item.lookUpMeaning_c); option.value = item.lookUpCode_c; select.append(option);
}
if (row.taskType && !taskTypes.some(item => item.lookUpCode_c === row.taskType)) {
const option = node("option", "", row.taskType); option.value = row.taskType; select.append(option);
}
select.value = row.taskType;
select.disabled = !entry.taskTypesReady || (!taskTypes.length && !row.taskType);
select.title = !entry.taskTypesReady ? "Loading Task Types" : taskTypes.length ? "Task Type" : "No Task Types available for this row type";
change = () => { row.taskType = select.value; updateSave(); };
} else { select.disabled = true; select.title = "Options are not available yet."; }
td.append(select); tr.append(td);
select.addEventListener("change", change);
const combo = searchableSelect(select, td, change, {
floating: true,
listWidth: label === "Service Request" ? 640 : 0,
listMaxHeight: label === "Service Request" ? 360 : 180,
tooltipContent: label === "Service Request" ? serviceRequestTooltip : undefined
});
combo.input.setAttribute("aria-label", `${label}, row ${index + 1}`);
combos.push(combo);
}
for (const day of entry.days) {
const td = node("td"), input = node("input");
input.type = "number"; input.min = "0"; input.max = "24"; input.step = "1";
input.value = row.hours[day.Date] ?? "";
input.setAttribute("aria-invalid", String(!!row.invalidHours[day.Date]));
input.setAttribute("aria-label", `Hours ${day.Date}, row ${index + 1}`);
input.addEventListener("input", () => {
const valid = !input.validity?.badInput && (input.value === "" || (Number.isFinite(input.valueAsNumber) && input.valueAsNumber >= 0 && input.valueAsNumber <= 24));
if (!valid) {
input.value = row.hours[day.Date] ?? "";
notify("Enter hours between 0 and 24.", true);
return;
}
row.invalidHours[day.Date] = !valid;
input.setAttribute("aria-invalid", String(!valid)); row.hours[day.Date] = input.value; updateTotals();
}); td.append(input); tr.append(td);
}
const notesCell = node("td"), notesRow = node("tr", "oe-time-notes"); notesRow.hidden = !row.notesOpen;
const noteCell = node("td"); noteCell.colSpan = entry.days.length + 6;
const notesBox = node("div", "oe-time-notes-box");
const remaining = node("span", "oe-time-notes-count", `${2000 - row.notes.length} characters left`);
const notes = node("textarea"); notes.rows = 6; notes.maxLength = 2000; notes.value = row.notes;
notes.setAttribute("aria-label", `Notes, row ${index + 1}`);
notes.addEventListener("input", () => { row.notes = notes.value.slice(0, 2000); remaining.textContent = `${2000 - row.notes.length} characters left`; updateSave(); });
const collapse = button(`Collapse notes, row ${index + 1}`, "up", () => {
row.notesOpen = false; notesRow.hidden = true; notesButton.setAttribute("aria-expanded", "false"); notesButton.focus();
});
collapse.className = "oe-time-notes-collapse";
notesBox.append(remaining, notes, collapse); noteCell.append(notesBox); notesRow.append(noteCell);
const notesButton = button(`Notes, row ${index + 1}`, "notes", () => {
row.notesOpen = !row.notesOpen; notesRow.hidden = !row.notesOpen; notesButton.setAttribute("aria-expanded", String(row.notesOpen));
if (!notesRow.hidden) notes.focus();
}); notesButton.setAttribute("aria-expanded", String(row.notesOpen)); notesCell.append(notesButton); tr.append(notesCell);
const copy = node("td"); copy.append(button(`Copy row ${index + 1}`, "copy", () => addRow(row.type, row))); tr.append(copy);
const remove = node("td"); remove.append(button(`Delete row ${index + 1}`, "trash", () => {
if (entry.saving) return;
if (row.persisted && !row.id) {
saveStatus.hidden = false; saveStatus.setAttribute("role", "alert");
saveStatus.textContent = "Reopen Manage Time to obtain the saved entry ID before deleting it."; return;
}
if (row.id) pendingDeletes().add(String(row.id));
entry.rows.splice(index, 1); renderRows();
saveStatus.hidden = false; saveStatus.setAttribute("role", "status");
saveStatus.textContent = pendingDeletes().size ? `${pendingDeletes().size} pending ${pendingDeletes().size === 1 ? "deletion" : "deletions"}. Click Save to apply.` : "Unsaved row removed.";
})); tr.append(remove);
entry.tbody.append(tr, notesRow);
}); updateTotals();
combos.find(combo => combo.input.id === focusedId)?.input.focus();
}
function serviceRequestTooltip(srNumber) {
const sr = entry.expanded.get(srNumber);
if (!sr) return null;
const details = node("dl", "oe-time-sr-details");
for (const [key, label] of [
["SrNumber", "SR number"], ["AccountPartyUniqueName", "Account"],
["OptyName_c", "Opportunity"], ["OptyNumber_c", "Opty number"],
["OptyOwner_c", "Opty owner"], ["OptyStatus_c", "Opty status"],
["ServiceNew_c", "Service"], ["ReportedByPartyUniqueName", "Requested by"],
["CountryText_c", "Country"], ["StatusCdMeaning", "Status"],
["AssigneePersonName", "Service lead"], ["ProblemDescription", "Description"]
]) {
const field = node("div", key === "ProblemDescription" ? "oe-time-sr-description" : "");
field.append(node("dt", "", label), node("dd", "", String(sr[key] ?? "-")));
details.append(field);
}
return details;
}
async function loadTaskTypes() {
taskStatus.className = "oe-time-status oe-time-loading-status";
taskStatus.hidden = false; taskStatus.textContent = "Loading Task Types...";
taskStatus.setAttribute("role", "status"); taskRetry.hidden = true;
try {
const items = await requestTaskTypes();
if (active !== entry) return;
entry.taskTypes = items.filter(item => typeof item?.lookUpCode_c === "string" && item.lookUpCode_c.trim()
&& typeof item.lookUpMeaning_c === "string" && item.lookUpMeaning_c.trim());
entry.taskTypesReady = true;
taskStatus.className = "oe-time-status";
taskStatus.textContent = "No Task Types available."; taskStatus.hidden = entry.taskTypes.length > 0;
if (entry.tbody) renderRows();
} catch (error) {
if (active !== entry) return;
taskStatus.className = "oe-time-status";
taskStatus.textContent = error.message || "Unable to load Task Types.";
taskStatus.setAttribute("role", "alert"); taskRetry.hidden = false;
}
}
async function loadServices(weekId, retrying = false) {
if (entry.servicesStarted && !retrying) return;
entry.servicesStarted = true; entry.initialWeekId = weekId;
serviceRetry.hidden = true; serviceStatus.hidden = false;
serviceStatus.className = "oe-time-status oe-time-loading-status";
serviceStatus.setAttribute("role", "status"); serviceStatus.textContent = "Loading Service Requests...";
try {
const services = entry.servicesReady ? entry.services : await requestServices(weekId);
if (active !== entry) return;
entry.services = services; entry.servicesReady = true;
serviceStatus.textContent = services.length ? "" : "No active Service Requests.";
serviceStatus.hidden = services.length > 0;
if (entry.tbody) renderRows();
serviceStatus.hidden = false; serviceStatus.textContent = "Loading activities...";
const expanded = await requestExpand(services.map(sr => sr.srNumber));
if (active !== entry) return;
entry.expanded = new Map(expanded.map(sr => [sr.SrNumber, sr]));
serviceStatus.hidden = true;
if (entry.tbody) renderRows();
} catch (error) {
if (active !== entry) return;
serviceStatus.className = "oe-time-status";
serviceStatus.textContent = error.message || "Unable to load Service Requests.";
serviceStatus.setAttribute("role", "alert"); serviceRetry.hidden = false;
}
}
function rowsFromEntries(records, days) {
return records.map(record => {
const row = blankRow(record.tag === "NON_SERVICE" || record.srNumber === "non-service-sr" ? "Non Service" : "Service");
row.id = record.id;
row.deliveryType = record.deliveryType || "";
row.srNumber = record.srNumber || "";
row.activity = record.activityCode || "";
row.taskType = record.taskType || "";
row.notes = typeof record.notes === "string" ? record.notes : "";
for (const day of days) {
const value = record[hourFields[dates.fromApi(day.Date).getUTCDay()]];
row.hours[day.Date] = value == null ? "" : String(value);
}
row.baseline = signature(row);
return row;
});
}
function renderWeek(items, records) {
entry.days = items;
const key = items.map(day => day.Date).join("|");
if (!entry.drafts.has(key)) entry.drafts.set(key, records.length ? rowsFromEntries(records, items) : Array.from({ length: 3 }, () => blankRow("Service")));
entry.rows = entry.drafts.get(key);
const head = node("thead"), row = node("tr");
for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label));
entry.dayTotals = [];
for (const day of items) {
const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day");
const dayLabel = node("span", "oe-time-day-label", ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()]);
const count = node("span", "oe-time-day-total", "0"); entry.dayTotals.push(count); dayLabel.append(count);
th.append(dayLabel, node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
}
for (const label of ["Notes", "Copy", ""]) row.append(node("th", "", label));
[...row.children].forEach(th => th.setAttribute("scope", "col"));
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody);
endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();
}
function renderSkeleton() {
const head = node("thead"), heading = node("tr"), skeletonBody = node("tbody");
for (let column = 0; column < 13; column++) {
const cell = node("th");
cell.append(node("span", "oe-time-skeleton oe-time-skeleton-heading")); heading.append(cell);
}
head.append(heading);
for (let row = 0; row < 3; row++) {
const tr = node("tr", "oe-time-skeleton-row");
for (let column = 0; column < 13; column++) {
const cell = node("td");
cell.append(node("span", `oe-time-skeleton ${column < 3 ? "oe-time-skeleton-select" : "oe-time-skeleton-control"}`)); tr.append(cell);
}
skeletonBody.append(tr);
}
table.setAttribute("aria-hidden", "true"); table.replaceChildren(head, skeletonBody);
}
function finishSkeleton() {
table.setAttribute("aria-hidden", "false");
endingValue.className = "";
Object.values(sums).forEach(value => { value.className = ""; });
status.className = "oe-time-status";
}
async function load() {
if (entry.saving) return;
finishSkeleton();
const version = ++entry.version; entry.tbody = null; table.replaceChildren(); endingValue.textContent = "--";
save.disabled = true; saveStatus.hidden = true;
Object.values(sums).forEach(value => { value.textContent = "--"; });
add.disabled = addNonService.disabled = true; retry.hidden = true; status.hidden = false;
status.setAttribute("role", "status"); scroll.setAttribute("aria-busy", "false");
if (!dates.parse(date.value)) { status.textContent = "Select a valid reference date."; return; }
status.className = "oe-time-status oe-time-loading-status";
endingValue.className = "oe-time-skeleton oe-time-skeleton-summary";
Object.values(sums).forEach(value => { value.className = "oe-time-skeleton oe-time-skeleton-summary"; });
renderSkeleton();
status.textContent = "Loading week details..."; scroll.setAttribute("aria-busy", "true");
try {
const items = await requestWeek(date.value);
if (active !== entry || entry.version !== version) return;
status.textContent = "Loading time entries...";
const key = items.map(day => day.Date).join("|");
const records = entry.drafts.has(key) ? [] : await requestEntries(items[0].WeekId);
if (active !== entry || entry.version !== version) return;
finishSkeleton(); renderWeek(items, records); add.disabled = addNonService.disabled = false; status.hidden = true;
loadServices(items[0].WeekId);
} catch (error) {
if (active !== entry || entry.version !== version) return;
finishSkeleton(); table.replaceChildren();
status.textContent = error.message || "Unable to load week details.";
status.setAttribute("role", "alert"); retry.hidden = false;
} finally {
if (active === entry && entry.version === version) scroll.setAttribute("aria-busy", "false");
}
}
date.focus(); load(); loadTaskTypes();
}
return { createButton, open, close, setReady };
}

View File

@@ -18,7 +18,7 @@ function createActivityProgress({ MODAL_ID, environment }) {
clearTimeout(entry.timer);
clearTimeout(entry.hideTimer);
entry.state = state;
const label = key === "sr" ? "Service Requests" : "Actions";
const label = key === "session" ? "SR session" : key === "sr" ? "Service Requests" : "Actions";
entry.badge.hidden = state === "idle";
entry.badge.setAttribute("data-state", state);
entry.badge.setAttribute("data-fading", "false");
@@ -27,7 +27,8 @@ function createActivityProgress({ MODAL_ID, environment }) {
entry.badge.textContent = state === "error" ? `${label} unavailable`
: state === "complete-with-errors" ? `${label} ${count} (errors)`
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
entry.badge.title = key === "sr" ? "CRM service request records loaded" : "Workload action requests completed";
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters";
if (state === "complete") entry.timer = setTimeout(() => {
entry.badge.setAttribute("data-fading", "true");
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;

View File

@@ -0,0 +1,105 @@
.opportunities-extension-views { min-height: 0; min-width: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 8px; }
.opportunities-extension-views > [hidden] { display: none; }
.opportunities-extension-view-tabs { display: flex; gap: 20px; border-bottom: 1px solid #dedbd7; }
.opportunities-extension-view-tabs button { border: 0; border-bottom: 3px solid transparent; background: transparent; color: #625d58; padding: 6px 2px 9px; font: 600 13px/20px Arial, sans-serif; cursor: pointer; }
.opportunities-extension-view-tabs button[aria-selected="true"] { border-bottom-color: #00758f; color: #00657b; }
.opportunities-extension-view-tabs button:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
.opportunities-extension-view-tabs button { display: inline-flex; align-items: center; gap: 6px; }
.opportunities-extension-view-tabs button svg { width: 16px; height: 16px; flex: 0 0 16px; }
.opportunities-extension-calendar {
--fc-border-color: #dedbd7; --fc-page-bg-color: #fff; --fc-neutral-bg-color: #f5f4f2;
--fc-neutral-text-color: #625d58; --fc-today-bg-color: #eef7fa;
--fc-button-bg-color: #fff; --fc-button-text-color: #312d2a; --fc-button-border-color: #b9b4ae;
--fc-button-hover-bg-color: #e6f3f5; --fc-button-hover-border-color: #00758f;
--fc-button-active-bg-color: #dceff3; --fc-button-active-border-color: #00758f;
min-height: 0; min-width: 0; overflow: hidden; display: flex; flex-direction: column; padding: 12px; background: #fff; color: #312d2a;
border: 1px solid var(--fc-border-color); border-radius: 4px; font: 13px/1.4 Arial, sans-serif;
}
.opportunities-calendar-grid { flex: 1 1 0; min-height: 0; min-width: 0; }
.opportunities-extension-calendar .fc { min-width: 620px; }
.opportunities-extension-calendar .fc .fc-toolbar { gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 18px; line-height: 26px; letter-spacing: 0; }
.opportunities-extension-calendar .fc .fc-button { font-size: 13px; line-height: 20px; padding: 5px 10px; border-radius: 4px; box-shadow: none; }
.opportunities-extension-calendar .fc .fc-button:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-calendar .fc .fc-button svg { width: 16px; height: 16px; margin-right: 6px; vertical-align: -3px; }
.opportunities-extension-calendar .fc a { color: inherit; text-decoration: none; }
.opportunities-extension-calendar .fc th { background: var(--fc-neutral-bg-color); font-size: 12px; font-weight: 600; padding: 4px 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-number { font-size: 11px; line-height: 18px; padding: 1px 6px; }
.opportunities-extension-calendar .fc .fc-daygrid-day-events { min-height: 0; margin-bottom: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-bottom { margin: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { min-height: 0; padding-top: 0; }
/* Event content must not contribute to the table's intrinsic row heights. */
.opportunities-extension-calendar .fc .fc-daygrid-body tbody { height: 100%; }
.opportunities-extension-calendar .fc .fc-daygrid-body tr { height: calc(100% / var(--opportunities-calendar-weeks, 5)); }
.opportunities-extension-calendar .fc .fc-daygrid-day { position: relative; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { position: absolute; inset: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-week-number { top: 0; left: 0; padding: 3px 6px; background: var(--fc-neutral-bg-color); color: inherit; font: 600 10px/16px Arial, sans-serif; white-space: nowrap; }
.opportunities-calendar-day-count { position: absolute; top: 2px; left: 6px; min-width: 18px; height: 16px; padding: 0 4px; border-radius: 8px; background: #dceff3; color: #005467; font: 600 10px/16px Arial, sans-serif; text-align: center; }
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 82px; }
.opportunities-calendar-day-count[hidden] { display: none; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-day-count:not([data-empty="true"]) { background: #16454f; color: #b9eaf4; }
.opportunities-extension-calendar .fc .fc-day-other { background: var(--fc-neutral-bg-color); }
.opportunities-extension-calendar .fc .fc-daygrid-event { white-space: normal; margin: 3px 4px; border-radius: 4px; cursor: pointer; border-width: 0 0 0 3px; border-style: solid; }
.opportunities-extension-calendar .fc .fc-event-main { color: inherit; }
.opportunities-calendar-event-content { min-width: 0; padding: 5px 6px; overflow-wrap: anywhere; }
.opportunities-calendar-event-content strong { display: block; font-size: 12px; line-height: 16px; font-weight: 600; }
.opportunities-calendar-event-content span { display: block; margin-top: 3px; font-size: 11px; line-height: 15px; }
.opportunities-calendar-event-content strong,
.opportunities-calendar-event-content span,
.opportunities-calendar-day-item strong,
.opportunities-calendar-day-item span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.opportunities-extension-calendar .fc .fc-daygrid-more-link { color: #00657b; font-size: 11px; line-height: 16px; padding: 0 5px; }
.opportunities-calendar-day-list { min-height: 0; overflow-y: auto; padding: 8px 20px 20px; }
.opportunities-calendar-day-item { display: block; width: 100%; padding: 12px 0; border: 0; border-bottom: 1px solid #dedbd7; background: transparent; color: #00657b; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
.opportunities-calendar-day-item strong, .opportunities-calendar-day-item span { display: block; font: 600 13px/20px Arial, sans-serif; }
.opportunities-calendar-day-item span { margin-top: 4px; font-weight: 400; }
.opportunities-calendar-day-item:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-day-item { border-bottom-color: #514d48; color: #80d6e8; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-SQL { background: #dff1f4; border-color: #00829d; color: #005467; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-PIPELINE,
.opportunities-extension-calendar .fc .opportunities-calendar-stage-OTHER { background: #efeeec; border-color: #706a64; color: #49443f; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-UPSIDE { background: #fff0da; border-color: #ba7000; color: #774700; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-FORECAST { background: #f0e8f7; border-color: #8262a3; color: #624379; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-WON { background: #e2f1e5; border-color: #2f8050; color: #215939; }
.opportunities-calendar-message { margin: 0 0 12px; font-size: 13px; }
.opportunities-calendar-message[hidden], .opportunities-calendar-grid[hidden], .opportunities-calendar-undated[hidden] { display: none; }
.opportunities-calendar-undated { flex: 0 0 auto; max-height: 90px; overflow-y: auto; margin-top: 6px; border-top: 1px solid var(--fc-border-color); padding-top: 4px; }
.opportunities-calendar-undated summary { cursor: pointer; font-size: 12px; line-height: 18px; }
.opportunities-calendar-undated h3 { font-size: 13px; margin: 0 0 8px; }
.opportunities-calendar-undated button { display: block; background: none; border: 0; text-align: left; font: inherit; padding: 6px 0; color: inherit; text-decoration: underline; cursor: pointer; overflow-wrap: anywhere; }
.opportunities-calendar-detail .opportunities-extension-account-plans-header { align-items: start; gap: 16px; }
.opportunities-calendar-detail .opportunities-extension-action-form-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
.opportunities-calendar-detail h2 { font-size: 18px; overflow-wrap: anywhere; }
.opportunities-calendar-detail-content { padding: 20px; overflow-y: auto; }
.opportunities-calendar-detail-content a { color: #006b82; }
.opportunities-calendar-detail-badges { display: flex; gap: 8px; margin-top: 18px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs { border-color: #514d48; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs button { color: #c7c2bc; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs button[aria-selected="true"] { color: #80d6e8; border-bottom-color: #80d6e8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar {
--fc-border-color: #514d48; --fc-page-bg-color: #292725; --fc-neutral-bg-color: #33302d;
--fc-neutral-text-color: #c7c2bc; --fc-today-bg-color: #223b3f;
--fc-button-bg-color: #292725; --fc-button-text-color: #eeeae6; --fc-button-border-color: #756f68;
--fc-button-hover-bg-color: #234850; --fc-button-hover-border-color: #80d6e8;
--fc-button-active-bg-color: #234850; --fc-button-active-border-color: #80d6e8;
background: #292725; color: #eeeae6;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-SQL { background: #16454f; color: #b9eaf4; border-color: #67cde0; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-PIPELINE,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-OTHER { background: #403c38; color: #e4dfd9; border-color: #b8b1a8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-UPSIDE { background: #533e1e; color: #ffe0a9; border-color: #dba44f; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-FORECAST { background: #493657; color: #ead5fc; border-color: #b998d8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-WON { background: #244a32; color: #c7edce; border-color: #84c396; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .fc-daygrid-more-link,
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-detail-content a { color: #80d6e8; }
@media (max-width: 700px) {
.opportunities-extension-body { overflow-y: auto; grid-template-rows: max-content max-content minmax(480px, 1fr); }
.opportunities-extension-views { min-height: 480px; }
.opportunities-extension-calendar { padding: 10px; overflow-x: auto; }
.opportunities-extension-calendar .fc { min-width: 0; }
.opportunities-extension-calendar .fc .fc-view-harness { min-width: 560px; }
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 16px; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { padding-top: 22px; }
.opportunities-calendar-day-count { top: 25px; }
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 6px; }
}

View File

@@ -0,0 +1,292 @@
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion,
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon }) {
const { document } = environment;
let elements = null;
let calendar = null;
let library = null;
let selectedView = "overview";
let anchoredVersion = null;
let details = null;
let lastEvents = "";
let dayCounts = new Map();
const dayBadges = new Map();
let resizeObserver = null;
let layoutFrame = null;
let eventLimit = 2;
function visibleEventLimit(height, weeks, headerHeight) {
return Math.max(0, Math.min(2, Math.floor((height / Math.max(1, weeks) - headerHeight - 26) / 48)));
}
function scheduleLayout() {
for (const [name, icon] of [["today", "today"], ["dayGridMonth", "calendar"], ["dayGridWeek", "week"]]) {
const button = elements?.calendarHost.querySelector?.(`.fc-${name}-button`);
if (button && !button.querySelector("svg")) button.prepend(createViewIcon(icon));
}
const win = environment.window;
if (!win?.requestAnimationFrame || layoutFrame !== null) return;
layoutFrame = win.requestAnimationFrame(() => {
layoutFrame = null;
if (!calendar || selectedView !== "calendar" || !elements.calendarHost.clientHeight) return;
calendar.updateSize();
const frame = elements.calendarHost.querySelector(".fc-daygrid-day-frame");
const events = frame?.querySelector(".fc-daygrid-day-events");
if (!frame || !events) return;
const scroller = frame.closest(".fc-scroller");
const weeks = elements.calendarHost.querySelectorAll(".fc-daygrid-body tr").length;
if (!scroller || !weeks) return;
elements.calendarHost.style.setProperty("--opportunities-calendar-weeks", String(weeks));
const headerHeight = events.getBoundingClientRect().top - frame.getBoundingClientRect().top;
const limit = visibleEventLimit(scroller.clientHeight, weeks, headerHeight);
if (limit !== eventLimit) { eventLimit = limit; calendar.setOption("dayMaxEvents", limit); }
});
}
function dateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function fiscalWeekLabel(date) {
// FullCalendar's week hook supplies a UTC date marker, not a local instant.
const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
const end = new Date(start.getTime() + 6 * 86400000);
const year = end.getUTCMonth() >= 5 ? end.getUTCFullYear() : end.getUTCFullYear() - 1;
const fiscalStart = new Date(Date.UTC(year, 5, 1));
const firstSunday = fiscalStart.getTime() - fiscalStart.getUTCDay() * 86400000;
const representative = start < fiscalStart ? fiscalStart : start;
const quarter = Math.floor(((representative.getUTCMonth() + 7) % 12) / 3) + 1;
const week = Math.round((start.getTime() - firstSunday) / (7 * 86400000)) + 1;
return `FY${String(year + 1).slice(-2)}-Q${quarter}-W${week}`;
}
function updateDayBadge(badge, key) {
const count = dayCounts.get(key) || 0;
badge.hidden = count === 0;
badge.textContent = String(count);
badge.title = `${count} oportunidade${count === 1 ? "" : "s"}`;
badge.setAttribute("aria-label", badge.title);
badge.setAttribute("data-empty", String(count === 0));
}
function node(tag, className, text) {
const el = document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function closeDate(value) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}(?:T|$)/.test(value)) return null;
const day = value.slice(0, 10);
const date = new Date(`${day}T00:00:00Z`);
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === day ? day : null;
}
function eventsFor(items) {
return items.flatMap((item, index) => {
const start = closeDate(item.EffectiveDate);
if (!start) return [];
return [{ id: String(item.OptyNumber || item.OptyId || index), title: String(item.Name || item.OptyNumber || "-"),
start, allDay: true, extendedProps: { item }, classNames: [`opportunities-calendar-stage-${stage(item)}`] }];
});
}
function stage(item) { return ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"].includes(item.ForecastGroup_c) ? item.ForecastGroup_c : "OTHER"; }
function initialDate(items, now = new Date()) {
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const dates = items.map(item => closeDate(item.EffectiveDate)).filter(Boolean).sort();
return dates.find(date => date >= today) || dates.at(-1) || today;
}
function closeDetails(restoreFocus = true) {
if (!details) return false;
const previous = details;
details = null;
previous.overlay.remove();
if (restoreFocus) (previous.trigger?.isConnected ? previous.trigger : elements?.tabs.calendar)?.focus();
return true;
}
function showDialog(heading, content, contentControls, trigger) {
closeDetails(false);
const host = document.getElementById(modalId);
if (!host || host.hidden) return;
const overlay = node("section", "opportunities-extension-action-form-modal opportunities-calendar-detail");
overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "opportunities-calendar-detail-title");
const panel = node("div", "opportunities-extension-action-form-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const title = node("h2", "", heading);
title.id = "opportunities-calendar-detail-title";
const dismiss = node("button", "opportunities-extension-icon-button");
dismiss.type = "button"; dismiss.title = "Close"; dismiss.setAttribute("aria-label", "Close opportunity details");
dismiss.append(createCloseIcon()); dismiss.addEventListener("click", () => closeDetails());
header.append(title, dismiss);
panel.append(header, content); overlay.append(panel); host.append(overlay);
details = { overlay, trigger };
overlay.addEventListener("click", event => { if (event.target === overlay) closeDetails(); });
overlay.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeDetails(); }
if (event.key === "Tab") {
const controls = [dismiss, ...contentControls];
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
}
});
dismiss.focus();
}
function showDetails(item, trigger) {
const content = node("div", "opportunities-calendar-detail-content");
const link = createDetailLink(item.OptyNumber || "-", item.OptyNumber ? opportunityUrl + encodeURIComponent(item.OptyNumber) : "");
const identity = node("div", "opportunities-extension-opty-number-content");
identity.append(link);
const copy = item.OptyNumber ? createCopyButton(item.OptyNumber) : null;
if (copy) identity.append(copy);
const summary = node("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [["Customer", item.CustomerAccount?.PartyUniqueName], ["Owner", getOwnerName(item)],
["Close Date", closeDate(item.EffectiveDate) ? formatDate(item.EffectiveDate) : "N/D"], ["Amount", formatRevenue(item)]]) {
summary.append(node("dt", "", label), node("dd", "", String(value || "-")));
}
const badges = node("div", "opportunities-calendar-detail-badges");
badges.append(createStatusBadge(item.StatusCode), createStageBadge(item.ForecastGroup_c));
content.append(identity, summary, badges);
showDialog(item.Name || item.OptyNumber || "Opportunity", content, item.OptyNumber ? [link, copy] : [], trigger);
}
function showDayOpportunities({ date, allSegs, jsEvent }) {
jsEvent.preventDefault();
const trigger = jsEvent.currentTarget || elements?.tabs.calendar;
const content = node("div", "opportunities-calendar-day-list");
const controls = [];
const events = new Map(allSegs.map(({ event }) => [event.id, event]));
for (const event of events.values()) {
const item = event.extendedProps.item;
const button = node("button", "opportunities-calendar-day-item"); button.type = "button";
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`;
button.append(node("strong", "", event.title), node("span", "", summary));
button.title = `${event.title}\n${summary}`;
button.addEventListener("click", () => showDetails(item, trigger));
controls.push(button); content.append(button);
}
showDialog(`${formatDate(date.toISOString().slice(0, 10))} | ${events.size} oportunidades`, content, controls, trigger);
// A truthy non-string result prevents FullCalendar from opening its measured popover.
return true;
}
function mount(body, tableSurface) {
resizeObserver?.disconnect();
if (layoutFrame !== null) environment.window?.cancelAnimationFrame(layoutFrame);
layoutFrame = null; eventLimit = 2;
calendar?.destroy(); calendar = null; lastEvents = ""; anchoredVersion = null; selectedView = "overview";
dayBadges.clear(); dayCounts.clear();
closeDetails(false);
const workspace = node("section", "opportunities-extension-views");
const tablist = node("div", "opportunities-extension-view-tabs");
tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-label", "Visualiza\u00e7\u00e3o das oportunidades");
const tabs = {};
const surface = node("section", "opportunities-extension-calendar");
const message = node("p", "opportunities-calendar-message"); message.setAttribute("role", "status");
const calendarHost = node("div", "opportunities-calendar-grid");
const undated = node("details", "opportunities-calendar-undated");
surface.append(message, calendarHost, undated);
for (const [key, label, panel] of [["overview", "Vis\u00e3o geral", tableSurface], ["calendar", "Calend\u00e1rio", surface]]) {
const tab = node("button", ""); tab.type = "button"; tab.id = `opportunities-view-${key}-tab`;
tab.append(createViewIcon(key), node("span", "", label));
panel.id = `opportunities-view-${key}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tab.id);
tab.setAttribute("role", "tab"); tab.setAttribute("aria-controls", panel.id);
tab.addEventListener("click", () => selectView(key));
tab.addEventListener("keydown", event => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" ? "overview" : event.key === "End" ? "calendar" : key === "overview" ? "calendar" : "overview";
selectView(next); tabs[next].focus();
});
tabs[key] = tab; tablist.append(tab);
}
workspace.append(tablist, tableSurface, surface); body.append(workspace);
elements = { workspace, tableSurface, surface, message, calendarHost, undated, tabs };
if (environment.window?.ResizeObserver) {
resizeObserver = new environment.window.ResizeObserver(scheduleLayout);
resizeObserver.observe(calendarHost);
}
selectView("overview");
}
function selectView(view) {
if (!elements || !["overview", "calendar"].includes(view)) return;
closeDetails(false); closeSrPopover(); selectedView = view;
elements.tableSurface.hidden = view !== "overview"; elements.surface.hidden = view !== "calendar";
for (const [key, tab] of Object.entries(elements.tabs)) {
tab.setAttribute("aria-selected", String(view === key)); tab.tabIndex = view === key ? 0 : -1;
}
render(getItems(), getState());
if (view === "calendar") calendar?.updateSize();
scheduleLayout();
}
function render(items, state) {
if (!elements || selectedView !== "calendar") return;
const { surface, message, calendarHost, undated } = elements;
surface.setAttribute("aria-busy", String(state.status === "loading"));
if (["loading", "error", "empty", "idle"].includes(state.status)) {
message.textContent = state.message || (state.status === "loading" ? "Loading opportunities..." : "No opportunities found.");
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; closeDetails(false); return;
}
message.textContent = items.length ? "" : "No opportunities found."; message.hidden = !!items.length;
calendarHost.hidden = false;
const events = eventsFor(items);
dayCounts = new Map();
for (const event of events) dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
for (const { badge, key } of dayBadges.values()) updateDayBadge(badge, key);
const anchor = initialDate(state.items);
if (!calendar) {
try {
library ||= loadLibrary();
calendar = new library.Calendar(calendarHost, {
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false,
datesSet: scheduleLayout,
locale: "pt-br", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
buttonText: { today: "Hoje", month: "M\u00eas", week: "Semana" },
buttonHints: { prev: "Anterior", next: "Pr\u00f3ximo", today: "Hoje", dayGridMonth: "M\u00eas", dayGridWeek: "Semana" },
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
moreLinkHint: count => `Ver mais ${count} oportunidades`,
moreLinkClick: showDayOpportunities,
weekNumbers: true,
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
dayCellDidMount: ({ el, date }) => {
const badge = node("span", "opportunities-calendar-day-count");
const key = dateKey(date);
updateDayBadge(badge, key);
el.querySelector(".fc-daygrid-day-frame").append(badge);
dayBadges.set(el, { badge, key });
},
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.delete(el); },
eventOrder: "title", eventDisplay: "block", events,
eventContent: ({ event }) => {
const item = event.extendedProps.item;
const card = node("div", "opportunities-calendar-event-content");
const summary = node("span", "", `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`);
summary.title = summary.textContent;
card.append(node("strong", "", event.title), summary);
return { domNodes: [card] };
},
eventDidMount: ({ el, event }) => {
el.title = `${event.title}\n${event.extendedProps.item.CustomerAccount?.PartyUniqueName || ""}\n${formatDate(event.startStr)} | ${event.extendedProps.item.ForecastGroup_c || "-"}`;
el.setAttribute("role", "button"); el.setAttribute("aria-label", el.title);
},
eventClick: ({ event, el, jsEvent }) => { jsEvent.preventDefault(); showDetails(event.extendedProps.item, el); }
});
calendar.render(); lastEvents = JSON.stringify(events); anchoredVersion = getVersion();
} catch {
calendar?.destroy(); calendar = null; message.textContent = "Unable to display the calendar. Use the overview or try again.";
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; return;
}
}
const signature = JSON.stringify(events);
calendar.batchRendering(() => {
if (anchoredVersion !== getVersion()) { calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false); }
if (signature !== lastEvents) { calendar.removeAllEvents(); calendar.addEventSource(events); lastEvents = signature; closeDetails(false); }
});
const missing = items.filter(item => !closeDate(item.EffectiveDate));
undated.hidden = !missing.length; undated.textContent = "";
if (missing.length) {
undated.append(node("summary", "", `Sem Close Date v\u00e1lido (${missing.length})`));
for (const item of missing) {
const button = node("button", "", `${item.OptyNumber || "-"} | ${item.Name || "-"}`); button.type = "button";
button.addEventListener("click", () => showDetails(item, button)); undated.append(button);
}
}
scheduleLayout();
}
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel, visibleEventLimit };
}

View File

@@ -15,6 +15,7 @@ function createOpportunitiesController({
requestOpportunitiesPage,
resetCrmServiceRequests,
resetWorkloadActions,
resetWorkloads,
requestOpptyProductsInBackground,
requestTokenRelayOnce,
ensureCurrentUser,
@@ -23,6 +24,8 @@ function createOpportunitiesController({
async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date());
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
resetWorkloadActions();
resetWorkloads();
await ensureCurrentUser();
if (!isCurrentPeriodRequest(requestVersion)) return;
@@ -108,6 +111,7 @@ function createOpportunitiesController({
function resetOpportunitiesTable() {
resetWorkloadActions();
resetWorkloads();
resetCrmServiceRequests();
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);

View File

@@ -1,10 +1,10 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, environment }) {
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment }) {
let status = "idle";
let byOpportunity = new Map();
let pending = null;
let generation = 0;
let loadedItems = [];
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
function reset() { closeSrForm(); closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
function load(token, version, cacheOptions = null) {
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
if (status === "ready") {
@@ -79,6 +79,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
`Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`,
`Customer Deadline: ${display(formatDate(item.CustomerDeadline_c))}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
@@ -89,6 +90,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
badge.setAttribute("aria-label", description);
badge.title = "Whether a Service Request has been created for the opportunities.";
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
badge.tabIndex = 0;
return badge;
}

View File

@@ -0,0 +1,347 @@
function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportunity, requestSave, notify, formatDate, requestPillars, requestLanguage, requestPlatform, requestServices, createCloseIcon, searchableSelect, environment }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;
const previous = active;
active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createButton(number) {
const button = node("button", "opportunities-extension-add-action", "+");
button.type = "button";
button.title = "Add Service Request";
button.setAttribute("aria-label", `Add Service Request for ${number}`);
button.setAttribute("aria-haspopup", "dialog");
button.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); open(number, button); });
return button;
}
function open(number, trigger) {
if (active?.saving) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden || !allowsCrmActivities() || !isMissing(number)) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-sr-form-title");
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-sr-form-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const heading = node("div", "opportunities-extension-action-form-heading");
const title = node("h2", "", "Add Service Request");
title.id = "opportunities-extension-sr-form-title";
heading.append(title, node("p", "", String(number)));
const opportunity = getOpportunity(number);
const details = node("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [
["Customer", opportunity?.CustomerAccount?.PartyUniqueName],
["Name", opportunity?.Name],
["Close Date", formatDate(opportunity?.EffectiveDate)]
]) {
details.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "-" : String(value)));
}
heading.append(details);
const dismiss = node("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.setAttribute("aria-label", "Close Service Request form");
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const form = node("form", "opportunities-extension-action-form");
form.noValidate = true;
const fields = new Map();
const combos = new Map();
const loadingFields = new Set(["primaryPillar", "language", "platform", "services"]);
const availableFields = new Set();
let serviceItems = [];
const serviceRadios = [];
let serviceGroup;
const field = node("label", "opportunities-extension-action-form-field");
const select = node("select");
select.id = "opportunities-extension-sr-primary-pillar";
select.name = "primaryPillar";
select.required = true;
field.htmlFor = select.id;
field.append(node("span", "", "Primary pillar (required)"), select);
const autocomplete = searchableSelect(select, field);
fields.set("primaryPillar", select);
combos.set(select, autocomplete);
const status = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
status.setAttribute("role", "status");
const footer = node("footer", "opportunities-extension-action-form-footer");
const retry = node("button", "", "Retry");
retry.type = "button";
const cancel = node("button", "", "Cancel");
const save = node("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(retry, save, cancel);
form.append(field);
const extraControls = [];
const extraLoads = [];
function textField(name, label, type) {
const wrapper = node("label", "opportunities-extension-action-form-field" + (type === "textarea" ? " opportunities-extension-action-form-wide" : ""));
const input = node(type === "textarea" ? "textarea" : "input");
if (type === "textarea") input.rows = 4;
else input.type = type;
input.id = `opportunities-extension-sr-${name}`;
input.name = name;
input.required = true;
fields.set(name, input);
wrapper.htmlFor = input.id;
wrapper.append(node("span", "", `${label} (required)`), input);
form.append(wrapper);
extraControls.push(input);
}
textField("customerDeadline", "Customer deadline", "date");
function lookupField(name, label, request, radios = false) {
const wrapper = node(radios ? "fieldset" : "div", "opportunities-extension-sr-lookup" + (radios ? " opportunities-extension-action-form-wide" : ""));
const content = node("div", radios ? "opportunities-extension-sr-services" : "");
const message = node("p", "opportunities-extension-action-form-status");
const retryOption = node("button", "opportunities-extension-sr-lookup-retry", "Retry");
retryOption.type = "button";
retryOption.setAttribute("aria-label", `Retry ${label}`);
retryOption.hidden = true;
extraControls.push(retryOption);
let input, combo;
if (radios) { wrapper.append(node("legend", "", `${label} (required)`)); serviceGroup = wrapper; }
else {
const field = node("label", "opportunities-extension-action-form-field");
input = node("select");
input.id = `opportunities-extension-sr-${name}`;
input.name = name;
input.required = true;
fields.set(name, input);
field.htmlFor = input.id;
field.append(node("span", "", `${label} (required)`), input);
combo = searchableSelect(input, field);
combos.set(input, combo);
content.append(field);
extraControls.push(combo.input);
}
wrapper.append(content, message, retryOption);
form.append(wrapper);
let loading = false;
async function loadOptions() {
if (loading || !current()) return;
loading = true;
loadingFields.add(name);
availableFields.delete(name);
updateSave();
retryOption.hidden = true;
message.hidden = false;
message.setAttribute("role", "status");
message.textContent = `Loading ${label.toLowerCase()}...`;
wrapper.setAttribute("aria-busy", "true");
if (input) { input.disabled = true; input.textContent = ""; combo.sync(); }
try {
const items = await request();
if (!current()) return;
if (items.length) availableFields.add(name);
if (input) {
const blank = node("option", "", items.length ? `Select ${label.toLowerCase()}` : "No options available");
blank.value = "";
input.append(blank);
items.forEach(item => { const option = node("option", "", item.name); option.value = item.code; input.append(option); });
input.value = "";
input.disabled = !items.length;
} else {
content.textContent = "";
serviceItems = items;
serviceRadios.length = 0;
const seen = new Set();
items.forEach((item, index) => {
if (seen.has(String(item.Id))) return;
seen.add(String(item.Id));
const row = node("label", "opportunities-extension-sr-service-option");
const radio = node("input");
radio.type = "radio";
radio.name = "service";
radio.required = true;
serviceRadios.push(radio);
radio.value = String(item.Id);
radio.id = `opportunities-extension-sr-service-${index}`;
const copy = node("span");
const abstract = node("span", "opportunities-extension-sr-service-abstract", item.Abstract_c || "");
abstract.id = `${radio.id}-description`;
radio.setAttribute("aria-describedby", abstract.id);
row.htmlFor = radio.id;
copy.append(node("span", "opportunities-extension-sr-service-name", item.RecordName), abstract);
row.append(radio, copy);
content.append(row);
extraControls.push(radio);
});
}
message.textContent = items.length ? "" : "No options available.";
message.hidden = !!items.length;
} catch (error) {
if (!current()) return;
message.setAttribute("role", "alert");
message.textContent = error.message || `Unable to load ${label.toLowerCase()}.`;
retryOption.hidden = false;
} finally {
loading = false;
loadingFields.delete(name);
if (current()) updateSave();
if (current()) { combo?.sync(); wrapper.setAttribute("aria-busy", "false"); }
}
}
retryOption.addEventListener("click", loadOptions);
extraLoads.push(loadOptions);
}
lookupField("language", "Language of delivery", requestLanguage);
lookupField("platform", "Platform", requestPlatform);
textField("description", "Description", "textarea");
lookupField("services", "Services", requestServices, true);
form.append(status, footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
const view = { modal, trigger, loading: false };
active = view;
const current = () => active === view && allowsCrmActivities();
function updateSave() { save.disabled = loadingFields.size > 0 || availableFields.size < 4 || Boolean(view.saving); }
form.addEventListener("submit", async event => {
event.preventDefault();
if (!current() || view.saving || save.disabled) return;
const invalid = [];
const value = name => String(fields.get(name)?.value || "").trim();
for (const [name, input] of fields) {
let valid = !!value(name);
if (combos.has(input)) valid = valid && Array.from(input.children).some(option => option.value === value(name));
if (name === "customerDeadline") {
const date = value(name);
valid = /^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(Date.parse(`${date}T00:00:00Z`))
&& new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) === date;
}
const control = combos.get(input)?.input || input;
control.setAttribute("aria-invalid", String(!valid));
if (!valid) invalid.push(control);
}
const selected = serviceRadios.find(radio => radio.checked);
const service = serviceItems.find(item => String(item.Id) === selected?.value);
serviceGroup.setAttribute("aria-invalid", String(!service));
if (!service) invalid.push(serviceRadios[0]);
if (invalid.length) {
status.hidden = false;
status.setAttribute("role", "alert");
status.textContent = "Complete the highlighted required fields with valid values.";
invalid[0]?.focus();
return;
}
const payload = {
AccountPartyId: opportunity?.CustomerAccount?.PartyId,
OpportunityName_Id_c: opportunity?.OptyId,
Title: `${service.RecordName}-${number}-${opportunity?.CustomerAccount?.PartyUniqueName || ""}`,
RegionText_c: opportunity?.CustomerAccount?.Region_c,
CountryText_c: opportunity?.CustomerAccount?.PrimaryAddress?.CountryCode,
ServiceNew_Id_c: service.Id,
ProblemDescription: value("description"), Language_c: value("language"),
CustomerDeadline_c: value("customerDeadline"), TechPlatformLookUp_c: value("platform"),
PrimaryPillarNew_c: value("primaryPillar")
};
view.saving = true;
const controls = [dismiss, retry, cancel, ...fields.values(), ...serviceRadios, ...extraControls];
const disabled = controls.map(control => [control, control.disabled]);
controls.forEach(control => { control.disabled = true; });
combos.forEach(combo => combo.sync());
updateSave();
save.textContent = "Preparing...";
form.setAttribute("aria-busy", "true");
status.hidden = true;
try {
const prepared = await requestSave({ phase: "prepare", payload });
if (!current() || host.hidden) return;
save.textContent = "Saving...";
await requestSave({ phase: "confirm", payload: prepared.payload });
view.saving = false;
close();
notify("Service Request saved successfully.");
} catch (error) {
status.hidden = false;
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to save Service Request.";
notify(status.textContent, true);
} finally {
view.saving = false;
if (current()) {
disabled.forEach(([control, wasDisabled]) => { control.disabled = wasDisabled; });
combos.forEach(combo => combo.sync());
updateSave();
save.textContent = "Save";
form.setAttribute("aria-busy", "false");
save.focus();
}
}
});
function option(value, text) { const item = node("option", "", text); item.value = value; select.append(item); }
async function load() {
if (view.loading || !current()) return;
view.loading = true;
loadingFields.add("primaryPillar");
availableFields.delete("primaryPillar");
updateSave();
retry.hidden = true;
select.disabled = true;
select.textContent = "";
option("", "Loading...");
autocomplete.sync();
form.setAttribute("data-state", "loading");
form.setAttribute("aria-busy", "true");
status.hidden = false;
status.setAttribute("role", "status");
status.textContent = "Loading primary pillars...";
try {
const items = await requestPillars();
if (!current()) return;
select.textContent = "";
option("", items.length ? "Select primary pillar" : "No options available");
items.forEach(item => option(item.code, item.name));
select.value = "";
select.disabled = !items.length;
if (items.length) availableFields.add("primaryPillar");
form.setAttribute("data-state", "ready");
status.hidden = true;
} catch (error) {
if (!current()) return;
form.setAttribute("data-state", "error");
select.textContent = "";
option("", "Unavailable");
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to load primary pillars.";
retry.hidden = false;
} finally {
view.loading = false;
loadingFields.delete("primaryPillar");
if (current()) updateSave();
if (current()) { autocomplete.sync(); form.setAttribute("aria-busy", "false"); }
}
}
retry.addEventListener("click", load);
modal.addEventListener("click", event => { if (event.target === modal) close(); });
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key !== "Tab") return;
const controls = [dismiss, autocomplete.input, ...extraControls, retry, save, cancel].filter(control => !control.disabled && !control.hidden);
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
});
dismiss.focus();
load();
extraLoads.forEach(loadOptions => loadOptions());
}
return { createButton, close, open };
}

View File

@@ -1,4 +1,4 @@
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, environment }) {
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, formatDate, openInternalSrForm, environment }) {
const { document, window, setTimeout, clearTimeout } = environment;
let active = null;
let sequence = 0;
@@ -91,6 +91,29 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
label.textContent = "SR Number:";
heading.append(label, link);
if (number) heading.append(createCopyButton(String(number), "SR number", "SR Number"));
const add = document.createElement("button");
add.type = "button";
add.className = "opportunities-extension-copy-button opportunities-extension-internal-sr-add";
add.title = "Create Internal Service Request";
add.setAttribute("aria-label", add.title);
add.setAttribute("aria-haspopup", "dialog");
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M12 5v14M5 12h14");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "2");
path.setAttribute("stroke-linecap", "round");
icon.append(path);
add.append(icon);
add.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
close();
openInternalSrForm(item, button);
});
heading.append(add);
const details = document.createElement("div");
details.className = "opportunities-extension-sr-details";
details.textContent = descriptions[index];
@@ -126,7 +149,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
body.className = "opportunities-extension-sr-details";
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/D" : String(record[key])}`).join("\n");
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/D" : String(key === "LastUpdateDate" || key === "OpenDate" ? formatDate(record[key]) : record[key])}`).join("\n");
child.append(row, body);
children.append(child);
}

View File

@@ -1,15 +1,15 @@
function createOpportunitiesFiscalPeriods() {
// Oracle fiscal years start on June 1; each range ends at the next period's start.
const FISCAL_START_MONTH = 5;
function getFiscalPeriodRange(period, currentDate) {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1;
const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3);
const fiscalStartYear = currentMonth >= FISCAL_START_MONTH ? currentYear : currentYear - 1;
const currentQuarterIndex = Math.floor(((currentMonth - FISCAL_START_MONTH + 12) % 12) / 3);
if (period === "Current Fiscal Year") {
const isBeforeFiscalYearStart = currentMonth < 5;
const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear;
const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1;
return createDateRange(startYear, 5, endYear, 4);
return createDateRange(fiscalStartYear, FISCAL_START_MONTH, fiscalStartYear + 1, FISCAL_START_MONTH);
}
let quarterOffset;
@@ -28,19 +28,14 @@ function createOpportunitiesFiscalPeriods() {
return null;
}
const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1);
const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0);
return {
startCloseDate: formatOracleDate(start),
endCloseDate: formatOracleDate(end)
};
const startMonth = FISCAL_START_MONTH + (quarterOffset * 3);
return createDateRange(fiscalStartYear, startMonth, fiscalStartYear, startMonth + (numberOfQuarters * 3));
}
function createDateRange(startYear, startMonth, endYear, endMonth) {
return {
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0))
endCloseDate: formatOracleDate(new Date(endYear, endMonth, 1))
};
}

View File

@@ -0,0 +1,197 @@
function createInternalSrForm({ MODAL_ID, searchableSelect, formatDate, getOpportunity, requestPlatform, requestServices, requestSave, notify, environment }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;
const previous = active;
active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function open(parent, trigger) {
if (active?.saving) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "internal-sr-form-title");
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-internal-sr-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const heading = node("div", "opportunities-extension-action-form-heading");
const title = node("h2", "", "Create Internal Service Request");
title.id = "internal-sr-form-title";
const context = node("dl", "opportunities-extension-action-form-context");
const opportunity = getOpportunity(parent.OpportunityName_Id_c?.OptyNumber);
for (const [label, value] of [["SR Number", parent.SrNumber], ["Opportunity", opportunity?.Name], ["Title", parent.Title]]) {
context.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "N/D" : String(value)));
}
heading.append(title, context);
header.append(heading);
const form = node("form", "opportunities-extension-action-form");
form.noValidate = true;
const fields = new Map();
const combos = new Map();
const ready = new Set();
function field(name, label, type, wide = false) {
const wrapper = node("label", "opportunities-extension-action-form-field" + (wide ? " opportunities-extension-action-form-wide" : ""));
const input = node(type === "textarea" || type === "select" ? type : "input");
if (!["textarea", "select"].includes(type)) input.type = type;
if (type === "textarea") input.rows = 4;
input.id = `internal-sr-${name}`;
input.name = name;
fields.set(name, input);
input.required = name !== "customerDeadline";
input.setAttribute("aria-required", String(input.required));
wrapper.htmlFor = input.id;
wrapper.append(node("span", "", input.required ? `${label} (required)` : label), input);
form.append(wrapper);
return { input, wrapper };
}
const titleInput = field("title", "Title", "text", true).input;
const deadline = field("customerDeadline", "Customer Deadline", "text").input;
deadline.readOnly = true;
deadline.value = formatDate(parent.CustomerDeadline_c) || "N/D";
field("internalDeadline", "Internal Deadline", "date");
const platform = field("platform", "Platform", "select", true);
field("description", "Description", "textarea", true);
const services = field("services", "Services", "select", true);
const footer = node("footer", "opportunities-extension-action-form-footer");
const cancel = node("button", "", "Cancel");
const save = node("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
const feedback = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
feedback.setAttribute("role", "status");
feedback.hidden = true;
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(save, cancel);
form.append(feedback, footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
const entry = { modal, trigger, saving: false };
active = entry;
const validDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(`${value}T00:00:00Z`))
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
form.addEventListener("submit", async event => {
event.preventDefault();
if (active !== entry || entry.saving || ready.size !== 2) return;
let firstInvalid = null;
for (const [name, input] of fields) {
if (!input.required) continue;
const value = String(input.value || "").trim();
const valid = !!value && (name !== "internalDeadline" || validDate(value))
&& (!combos.has(name) || Array.from(input.children).some(option => option.value === value));
input.setAttribute("aria-invalid", String(!valid));
const visible = combos.get(name)?.input || input;
visible.setAttribute("aria-invalid", String(!valid));
if (!valid && !firstInvalid) firstInvalid = visible;
}
feedback.hidden = false;
if (firstInvalid) {
feedback.setAttribute("role", "alert");
feedback.textContent = "Complete all required fields with valid values.";
firstInvalid.focus();
return;
}
const value = name => String(fields.get(name).value || "").trim();
const dateParts = value("customerDeadline").match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
const payload = {
Title: value("title"),
ServiceNew_Id_c: value("services"),
ProblemDescription: value("description"),
CustomerDeadline_c: dateParts ? `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}` : "",
TechPlatformLookUp_c: value("platform"),
ServiceRequest_Id_SRToInternalSR: String(parent.SrId ?? ""),
SvcQuestionsCollection_c: [], InternalDeadline_c: value("internalDeadline")
};
entry.saving = true;
form.setAttribute("aria-busy", "true");
save.disabled = cancel.disabled = true;
save.textContent = "Saving...";
for (const [name, input] of fields) { input.disabled = true; combos.get(name)?.sync(); }
feedback.setAttribute("role", "status");
feedback.textContent = "Creating Internal Service Request...";
try {
await requestSave(payload);
entry.saving = false;
close();
notify("Internal Service Request created successfully.");
} catch (error) {
if (active !== entry) return;
feedback.setAttribute("role", "alert");
feedback.textContent = error.message || "Unable to create Internal Service Request.";
notify(feedback.textContent, true);
} finally {
entry.saving = false;
if (active === entry) {
form.setAttribute("aria-busy", "false");
save.disabled = cancel.disabled = false;
save.textContent = "Save";
for (const [name, input] of fields) { input.disabled = false; combos.get(name)?.sync(); }
}
}
});
function load(target, request, service) {
const { input, wrapper } = target;
input.disabled = true;
const combo = searchableSelect(input, wrapper);
combos.set(input.name, combo);
const status = node("p", "opportunities-extension-action-form-status", "Loading...");
status.setAttribute("role", "status");
wrapper.append(status);
wrapper.setAttribute("aria-busy", "true");
Promise.resolve().then(request).then(items => {
if (active !== entry) return;
const placeholder = node("option", "", items.length ? "Select an option" : "No options available");
placeholder.value = "";
input.append(placeholder);
const seen = new Set();
for (const item of items) {
const value = String(service ? item.Id : item.code);
if (seen.has(value)) continue;
seen.add(value);
const option = node("option", "", service ? item.RecordName : item.name);
option.value = value;
if (service && item.Description_c) option.setAttribute("data-description", item.Description_c);
input.append(option);
}
input.value = "";
input.disabled = !items.length;
if (items.length) ready.add(input.name);
save.disabled = ready.size !== 2;
status.textContent = items.length ? "" : "No options available.";
status.hidden = !!items.length;
combo.sync();
}).catch(error => {
if (active !== entry) return;
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to load options. Close and reopen the form to retry.";
}).finally(() => { if (active === entry) wrapper.setAttribute("aria-busy", "false"); });
}
load(platform, requestPlatform, false);
load(services, () => requestServices(parent.SrId), true);
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const focusable = Array.from(form.querySelectorAll("input, textarea, button")).filter(el => !el.disabled && !el.hidden);
const first = focusable[0], last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
});
titleInput.focus();
}
return { open, close };
}

View File

@@ -0,0 +1,60 @@
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
const { document, setTimeout } = environment;
let recovery = null;
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
const now = () => environment.performance?.now() ?? Date.now();
function isUnauthorized(result) {
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
if (status != null) return Number(status) === 401;
// Older background workers may return only the request error text.
return /failed with status 401\.$/.test(result?.error || "");
}
async function warmSession(id) {
const frame = document.createElement("iframe");
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
frame.title = `${sessionLabel} session initialization`;
frame.tabIndex = -1;
frame.setAttribute("aria-hidden", "true");
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;";
let blocked = false;
let loaded = false;
frame.addEventListener("error", () => { blocked = true; });
frame.addEventListener("load", () => { loaded = true; });
try {
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
document.body.append(frame);
const deadline = now() + 60000;
for (let attempt = 0; now() < deadline; attempt += 1) {
// Check immediately, then briefly poll faster before backing off.
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now()));
if (now() >= deadline) break;
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`);
const result = await query(id);
if (result?.ok) return { id, result };
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
}
throw new Error(`${sessionLabel} session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`);
} finally {
// Stop remaining page resources without stopping the caller's page.
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ }
frame.src = "about:blank";
frame.remove();
}
}
async function request(id) {
let result = await query(id);
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) {
if (!recovery) {
recovery = warmSession(id);
const pending = recovery;
pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {});
}
const recovered = await recovery;
result = recovered.id === id ? recovered.result : await query(id);
}
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
return result.items;
}
return { request };
}

View File

@@ -1,20 +1,22 @@
function createProductActivities({ productsContentState, opportunitiesQueryState,
loadCrm, loadActions, blockActions, render }) {
let cycle = null;
function begin(requestId, token) {
cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false };
function begin(requestId, token, opportunityNumbers = null) {
cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), version: opportunitiesQueryState.periodRequestVersion, completed: false };
}
function complete(requestId) {
const current = cycle;
if (!current || current.completed || current.requestId !== requestId
|| productsContentState.currentProductsRequestId !== requestId) return;
current.completed = true;
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
const entries = [...productsContentState.opptyProductsByNumber].filter(([number]) => !current.numbers || current.numbers.has(String(number)));
const products = entries.map(([, result]) => result).filter(result => !result.error);
if (!products.length) return;
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
&& opportunitiesQueryState.periodRequestVersion === current.version;
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => {
// Product cache hits do not guarantee that the corresponding activity cache exists.
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: false })).then(async success => {
if (!isActive()) return;
if (success === false) {
products.forEach(blockActions);
@@ -30,7 +32,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
seen.add(id);
return true;
});
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true }));
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: false }));
}
await Promise.allSettled(pending);
if (!isActive()) return;

View File

@@ -0,0 +1,175 @@
function createSearchableSelect({ environment }) {
const { document } = environment;
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function searchableSelect(select, field, onChange, { floating = false, tooltipContent, listWidth = 0, listMaxHeight = 180 } = {}) {
const box = element("div", "opportunities-extension-action-combobox");
const input = element("input");
input.type = "text";
input.autocomplete = "off";
input.required = !!select.required;
input.setAttribute("aria-required", String(!!select.required));
select.addEventListener("invalid", event => { event.preventDefault(); input.focus(); });
input.id = `${select.id}-search`;
field.htmlFor = input.id;
const list = element("div", "opportunities-extension-action-options");
list.id = `${select.id}-options`;
list.setAttribute("role", "listbox");
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-controls", list.id);
input.setAttribute("aria-expanded", "false");
select.hidden = true;
select.tabIndex = -1;
box.append(input, list);
field.append(box);
const tooltip = tooltipContent ? element("div", "opportunities-extension-select-tooltip") : null;
if (tooltip) {
tooltip.id = `${select.id}-tooltip`;
tooltip.setAttribute("role", "tooltip");
tooltip.hidden = true;
box.append(tooltip);
}
let tooltipAnchor = null;
function hideTooltip() {
if (!tooltip) return;
tooltip.hidden = true;
tooltipAnchor?.removeAttribute?.("aria-describedby");
tooltipAnchor = null;
}
function showTooltip(value, anchor) {
hideTooltip();
if (!tooltip || !value) return;
const content = tooltipContent(value);
if (!content) return;
tooltip.replaceChildren(content);
tooltip.hidden = false;
tooltipAnchor = anchor;
anchor.setAttribute("aria-describedby", tooltip.id);
const rect = anchor.getBoundingClientRect?.();
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
if (!rect || !bounds) return;
const size = tooltip.getBoundingClientRect();
const left = rect.right + 8 + size.width <= bounds.right ? rect.right + 8 : Math.max(bounds.left + 8, rect.left - size.width - 8);
tooltip.style.left = `${left}px`;
tooltip.style.top = `${Math.max(bounds.top + 8, Math.min(rect.top, bounds.bottom - size.height - 8))}px`;
}
function leaveTooltip(event) {
if (!tooltip?.contains?.(event.relatedTarget)) hideTooltip();
}
tooltip?.addEventListener("mouseleave", hideTooltip);
list.addEventListener("scroll", hideTooltip);
let matches = [], index = -1;
const options = () => Array.from(select.children).filter(item => item.value);
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
function closeList() {
hideTooltip();
list.hidden = true;
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
}
function sync() {
closeList();
input.disabled = !!select.disabled;
input.placeholder = select.children[0]?.textContent || "Select";
input.value = options().find(item => item.value === select.value)?.textContent || "";
input.title = tooltipContent ? "" : input.value;
}
function highlight(withTooltip = true) {
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
list.children[index]?.scrollIntoView?.({ block: "nearest" });
if (withTooltip && matches[index]) showTooltip(matches[index].value, list.children[index]);
}
function choose(i) {
if (!matches[i]) return;
const changed = select.value !== matches[i].value;
select.value = matches[i].value;
sync();
if (changed) onChange?.();
}
function positionList() {
let bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
const anchor = input.getBoundingClientRect?.();
if (!bounds || !anchor) return;
if (floating && listWidth && document.documentElement?.clientWidth) {
bounds = { top: 0, left: 0, right: document.documentElement.clientWidth, bottom: document.documentElement.clientHeight };
}
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
const above = Math.max(0, anchor.top - bounds.top - 8);
const opensAbove = below < listMaxHeight && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(listMaxHeight, opensAbove ? above : below)}px`;
if (floating) {
const width = listWidth ? Math.min(Math.max(anchor.width, listWidth), bounds.right - bounds.left - 16) : anchor.width;
list.style.position = "fixed";
list.style.left = `${listWidth ? Math.max(bounds.left + 8, Math.min(anchor.left, bounds.right - width - 8)) : anchor.left}px`;
list.style.width = `${width}px`;
list.style.right = "auto";
list.style.bottom = "auto";
list.style.top = `${opensAbove ? anchor.top - 4 : anchor.bottom + 4}px`;
list.style.transform = opensAbove ? "translateY(-100%)" : "none";
}
}
function render(query = "") {
if (input.disabled) return;
hideTooltip();
matches = options().filter(item => normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
index = matches.findIndex(item => item.value === select.value);
list.textContent = "";
matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent);
const description = item.getAttribute?.("data-description");
if (description) row.append(element("small", "opportunities-extension-option-description", description));
row.id = `${list.id}-${i}`;
row.setAttribute("role", "option");
row.setAttribute("aria-selected", "false");
row.addEventListener("mousedown", event => event.preventDefault());
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
if (tooltip) {
row.addEventListener("mouseenter", () => showTooltip(item.value, row));
row.addEventListener("mouseleave", leaveTooltip);
}
list.append(row);
});
if (!matches.length) {
const empty = element("div", "opportunities-extension-action-option", "No results");
empty.setAttribute("role", "status");
list.append(empty);
}
positionList();
list.hidden = false;
input.setAttribute("aria-expanded", "true");
highlight(false);
}
input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value));
input.addEventListener("blur", sync);
if (tooltip) {
input.addEventListener("mouseenter", () => { if (list.hidden) showTooltip(select.value, input); });
input.addEventListener("mouseleave", leaveTooltip);
}
input.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (list.hidden) render();
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
highlight();
} else if (event.key === "Enter" && !list.hidden) {
event.preventDefault();
choose(index < 0 && matches.length === 1 ? 0 : index);
} else if (event.key === "Escape" && (!list.hidden || (tooltip && !tooltip.hidden))) {
event.preventDefault();
event.stopPropagation();
sync();
} else if (event.key === "Tab") sync();
});
sync();
return { input, sync };
}
return searchableSelect;
}

View File

@@ -0,0 +1,22 @@
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog, onReady = async () => {} }) {
let pending = null;
function ensure() {
if (pending) return pending;
pending = (async () => {
try {
await ensureCurrentUser();
if (!allowsCrmActivities()) return;
reportProgress("running");
const result = await sendRequest();
appendLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
reportProgress("complete");
await onReady();
} catch {
reportProgress("error");
}
})();
return pending;
}
return { ensure };
}

View File

@@ -309,6 +309,36 @@
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
.opportunities-extension-action-form-field select { height: 38px; }
.opportunities-extension-action-form-field select[hidden] { display: none; }
.opportunities-extension-sr-form-dialog { width: min(900px, 100%); }
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 20px 24px; }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { gap: 20px 24px; padding: 24px; align-items: start; }
.opportunities-extension-sr-form-dialog .opportunities-extension-action-form-field { gap: 8px; }
.opportunities-extension-sr-form-dialog textarea { min-height: 112px; }
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-option { padding: 12px 0; gap: 10px; }
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-abstract { font-size: 13px; line-height: 20px; }
@media (min-width: 1000px) {
.opportunities-extension-sr-form-dialog { width: min(1120px, 100%); }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px 24px; }
.opportunities-extension-sr-services { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 28px; align-items: start; }
.opportunities-extension-sr-services .opportunities-extension-sr-service-option { margin: 0; border-top: 1px solid #aaa6a040; }
.opportunities-extension-sr-services .opportunities-extension-sr-service-option:nth-child(-n+2) { border-top: 0; }
}
@media (max-width: 600px) {
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 16px; }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); gap: 18px; padding: 16px; }
}
.opportunities-extension-sr-lookup { min-width: 0; padding: 0; margin: 0; border: 0; font-size: 13px; }
.opportunities-extension-sr-lookup legend { padding: 0 0 8px; font-weight: 600; }
.opportunities-extension-sr-lookup[aria-busy="true"] > p { opacity: .65; }
.opportunities-extension-sr-service-option { display: flex; align-items: flex-start; gap: 8px; padding: 9px 0; cursor: pointer; }
.opportunities-extension-sr-service-option + .opportunities-extension-sr-service-option { border-top: 1px solid #aaa6a040; }
.opportunities-extension-sr-service-option input { flex: 0 0 16px; width: 16px; height: 16px; margin: 2px 0 0; accent-color: #00758f; }
.opportunities-extension-sr-service-option > span { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-sr-service-name { display: block; font-weight: 600; line-height: 20px; }
.opportunities-extension-sr-service-abstract { display: block; margin-top: 3px; color: #625d58; font-size: 12px; line-height: 18px; white-space: pre-wrap; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-service-abstract { color: #c7c2bc; }
.opportunities-extension-sr-lookup-retry { color: inherit; background: transparent; border: 1px solid #8f8983; border-radius: 4px; padding: 5px 10px; cursor: pointer; }
.opportunities-extension-sr-lookup-retry[hidden] { display: none; }
.opportunities-extension-action-form [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
.opportunities-extension-modal .opportunities-extension-copy-toast { z-index: 20; }
@@ -585,3 +615,5 @@
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; }
}
.opportunities-extension-option-description { display: block; font-size: 12px; line-height: 1.4; margin-top: 4px; white-space: normal; overflow-wrap: anywhere; opacity: .85; }
.opportunities-extension-action-form-dialog.opportunities-extension-internal-sr-dialog { width: min(760px, 100%); }

View File

@@ -10,6 +10,7 @@ function createOpportunitiesTable({
createDetailLink,
createProductsCountButton,
createProductsRampBadge,
createProjectionIndicator,
createCrmServiceRequestBadge,
closeCrmSrPopover,
createWorkloadActionsBadge,
@@ -25,6 +26,7 @@ function createOpportunitiesTable({
renderOwnerFilter,
renderActivityFilters,
renderStageDashboard,
renderCalendar,
resetCustomerFilter,
resetOwnerFilter,
environment
@@ -102,6 +104,7 @@ function createOpportunitiesTable({
const tableBody = document.createElement("tbody");
const items = getSortedOpportunities();
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
renderCalendar?.(items, opportunitiesTableState.opportunitiesTableState);
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
const row = document.createElement("tr");
@@ -144,6 +147,9 @@ function createOpportunitiesTable({
if (srBadge) productActions.append(srBadge);
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
if (actionsBadge) productActions.append(actionsBadge);
const projectionIndicator = createProjectionIndicator(productsResult, item,
productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber)));
if (projectionIndicator) productActions.append(projectionIndicator);
nameCellContent.append(opportunityLink, productActions);
cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) {

View File

@@ -1,5 +1,5 @@
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, environment }) {
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, searchableSelect, environment }) {
const { document } = environment;
let active = null;
function element(tag, className, text) {
@@ -8,109 +8,6 @@ function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingActi
if (text !== undefined) node.textContent = text;
return node;
}
function searchableSelect(select, field, onChange) {
const box = element("div", "opportunities-extension-action-combobox");
const input = element("input");
input.type = "text";
input.autocomplete = "off";
input.required = !!select.required;
input.setAttribute("aria-required", String(!!select.required));
select.addEventListener("invalid", event => { event.preventDefault(); input.focus(); });
input.id = `${select.id}-search`;
field.htmlFor = input.id;
const list = element("div", "opportunities-extension-action-options");
list.id = `${select.id}-options`;
list.setAttribute("role", "listbox");
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-controls", list.id);
input.setAttribute("aria-expanded", "false");
select.hidden = true;
select.tabIndex = -1;
box.append(input, list);
field.append(box);
let matches = [], index = -1;
const options = () => Array.from(select.children).filter(item => item.value);
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
function closeList() {
list.hidden = true;
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
}
function sync() {
closeList();
input.disabled = !!select.disabled;
input.placeholder = select.children[0]?.textContent || "Select";
input.value = options().find(item => item.value === select.value)?.textContent || "";
input.title = input.value;
}
function highlight() {
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
list.children[index]?.scrollIntoView?.({ block: "nearest" });
}
function choose(i) {
if (!matches[i]) return;
const changed = select.value !== matches[i].value;
select.value = matches[i].value;
sync();
if (changed) onChange?.();
}
function positionList() {
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
const anchor = input.getBoundingClientRect?.();
if (!bounds || !anchor) return;
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
const above = Math.max(0, anchor.top - bounds.top - 8);
const opensAbove = below < 180 && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`;
}
function render(query = "") {
if (input.disabled) return;
matches = options().filter(item => normalize(`${item.textContent} ${item.value}`).includes(normalize(query)));
index = -1;
list.textContent = "";
matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent);
row.id = `${list.id}-${i}`;
row.setAttribute("role", "option");
row.setAttribute("aria-selected", "false");
row.addEventListener("mousedown", event => event.preventDefault());
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
list.append(row);
});
if (!matches.length) {
const empty = element("div", "opportunities-extension-action-option", "No results");
empty.setAttribute("role", "status");
list.append(empty);
}
positionList();
list.hidden = false;
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-activedescendant", "");
}
input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value));
input.addEventListener("blur", sync);
input.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (list.hidden) render();
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
highlight();
} else if (event.key === "Enter" && !list.hidden) {
event.preventDefault();
choose(index < 0 && matches.length === 1 ? 0 : index);
} else if (event.key === "Escape" && !list.hidden) {
event.preventDefault();
event.stopPropagation();
sync();
} else if (event.key === "Tab") sync();
});
sync();
return { input, sync };
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;

View File

@@ -52,6 +52,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
for (const id of idsFor(products)) {
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
}
updateProgress(session);
}
function pump(current) {
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
@@ -160,6 +161,8 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products."
: state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests.";
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled.";
const errors = [...new Set(records.filter(record => record?.error).map(record => record.error))];
if (errors.length) badge.title += `\n${errors.join("\n")}`;
if (!principal) badge.title += "\nTokenrelay principal is unavailable; ownership cannot be verified.";
else if (actions.length && state === "missing") badge.title += "\nNone of the returned actions belong to the signed-in principal.";
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";

View File

@@ -0,0 +1,69 @@
function createFormRequestCache({ getHours, getUserId }) {
const entries = new Map();
const pending = new Map();
let generation = 0;
let owner = "";
const queries = new Set([
"requestWorkloads",
"requestWeekDetails", "requestTimeEntries", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
]);
const writes = new Set(["requestTimeEntriesDelete", "requestTimeEntriesSave", "requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest", "requestWorkloadForecastUpdate"]);
function invalidate() { generation++; entries.clear(); pending.clear(); }
function maxAge() {
const hours = Number(getHours());
return (Number.isFinite(hours) && hours > 0 ? Math.min(hours, 168) : 24) * 3600000;
}
function copy(value) { return JSON.parse(JSON.stringify(value)); }
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
return value;
}
async function send(message, transport) {
const userId = String(getUserId() || "").trim().toLowerCase();
if (userId !== owner) { invalidate(); owner = userId; }
const operation = String(message.type || "").split(".").pop();
const duration = maxAge();
const request = queries.has(operation) || writes.has(operation) || operation === "requestResourceUser"
? { ...message, cacheMaxAgeMs: duration } : message;
if (operation === "invalidateProductsCache") invalidate();
// Only explicitly known read operations are reusable. Writes and token generation always execute.
if (!queries.has(operation) || !userId) {
const result = await transport(request);
if (result?.ok && writes.has(operation) && !(operation === "requestPostServiceRequest" && message.phase === "prepare")) invalidate();
return result;
}
// Workloads are scoped to the user/account, not the rotating WWB token or the selected Reference.
const key = JSON.stringify(stable(operation === "requestWorkloads"
? { type: message.type, customerId: String(message.customerId), principal: String(message.principal || userId).trim().toLowerCase() }
: message));
const hit = entries.get(key);
if (hit && Date.now() >= hit.fetchedAt && Date.now() - hit.fetchedAt < duration) {
return { ...copy(hit.result), debugRequests: [], fromCache: true, cachedAt: hit.fetchedAt };
}
entries.delete(key);
if (pending.has(key)) {
const result = await pending.get(key);
return { ...copy(result), debugRequests: [] };
}
const version = generation;
const promise = transport(request).then(result => {
const workload = operation === "requestWorkloads";
const fetchedAt = workload && Number.isFinite(result?.cachedAt) ? result.cachedAt : Date.now();
const cacheable = !workload || (Array.isArray(result?.workloads) && (!result.fromCache || Number.isFinite(result.cachedAt)));
if (result?.ok && cacheable && version === generation) {
// Keep this per-page cache bounded and avoid persisting credentials or customer data to disk.
if (entries.size >= 500) entries.delete(entries.keys().next().value);
// Reusing a persistent workload entry must not restart its original TTL.
entries.set(key, { result: copy({ ...result, debugRequests: [] }), fetchedAt });
}
return result;
});
pending.set(key, promise);
try { return await promise; }
finally { if (pending.get(key) === promise) pending.delete(key); }
}
return { send, invalidate };
}

View File

@@ -102,11 +102,11 @@ function createPreferencesView({
cacheField.className = "opportunities-extension-preferences-cache-field";
const cacheLabel = document.createElement("span");
cacheLabel.textContent = "Product cache duration";
cacheLabel.textContent = "Cache duration";
const cacheSelect = document.createElement("select");
cacheSelect.name = "opptyProductsCacheHours";
cacheSelect.setAttribute("aria-label", "Product cache duration");
cacheSelect.setAttribute("aria-label", "Cache duration");
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
const optionElement = document.createElement("option");
optionElement.value = String(option.value);

View File

@@ -9,6 +9,8 @@ function createProductsContentJobs({
renderOpportunitiesTable,
beginProductActivities,
completeProductActivities,
loadWorkloads,
joinWorkloads,
sendRuntimeMessage,
environment
}) {
@@ -53,7 +55,7 @@ function createProductsContentJobs({
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
productsContentState.currentProductsRequestId = requestId;
beginProductActivities(requestId, token);
beginProductActivities(requestId, token, optyNumbers);
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
@@ -65,6 +67,8 @@ function createProductsContentJobs({
};
renderProductsProgress();
loadWorkloads(opportunities, requestId);
if (optyNumbers.length === 0) {
return;
}
@@ -121,6 +125,7 @@ function createProductsContentJobs({
cachedAt: Number(message.cachedAt) || 0,
error: message.error || ""
});
joinWorkloads(message.optyNumber);
}
productsContentState.productsProgress = {

View File

@@ -3,6 +3,8 @@ function createProductsContentState() {
currentProductsRequestId: "",
productsRenderQueued: false,
opptyProductsByNumber: new Map(),
workloadCustomerIds: [],
workloadsByCustomer: new Map(),
expandedProductOptyNumbers: new Set(),
productsProgress: {
state: "idle",

View File

@@ -2,6 +2,7 @@ function createProductsDetails({
OPPORTUNITIES_COLUMNS,
setBadgeContent,
createAiSummaryAlert,
createProjectionIndicator,
formatOracleResponseDate,
productsContentState,
renderOpportunitiesTable,
@@ -18,12 +19,20 @@ function createProductsDetails({
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/D")));
setBadgeContent(badge, "ramp", values.join(","));
setBadgeContent(badge, "ramp", values.join(" | "));
badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("aria-label", badge.title);
return badge;
}
function expandProducts(optyNumber) {
const number = String(optyNumber);
if (productsContentState.expandedProductOptyNumbers.has(number)) return;
productsContentState.expandedProductOptyNumbers.add(number);
requestGenerateAiSummary(number);
renderOpportunitiesTable();
}
function createProductsCountButton(optyNumber, productsResult) {
const normalizedOptyNumber = String(optyNumber);
const button = document.createElement("button");
@@ -44,8 +53,8 @@ function createProductsDetails({
if (isExpanded) {
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
} else {
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
requestGenerateAiSummary(normalizedOptyNumber);
expandProducts(normalizedOptyNumber);
return;
}
renderOpportunitiesTable();
@@ -105,7 +114,8 @@ function createProductsDetails({
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
? "N/D" : formatProductNumber(item.RampMonths_c) }
? "N/D" : formatProductNumber(item.RampMonths_c) },
{ label: "Projection", render: (item) => createProjectionIndicator(item, opportunity) }
];
const head = document.createElement("thead");
@@ -123,7 +133,10 @@ function createProductsDetails({
const productRow = document.createElement("tr");
columns.forEach((column) => {
const productCell = document.createElement("td");
productCell.textContent = String(column.value(product));
if (column.render) {
productCell.className = "opportunities-extension-projection-cell";
productCell.append(column.render(product));
} else productCell.textContent = String(column.value(product));
productRow.append(productCell);
});
body.append(productRow);
@@ -165,5 +178,5 @@ function createProductsDetails({
return Number.isFinite(number) ? `${number}%` : "-";
}
return { createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
return { expandProducts, createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
}

View File

@@ -0,0 +1,47 @@
function createWorkloadForecastUpdate({ compare, ensureCurrentUser, getCurrentUserId, requestAuthorization,
sendRuntimeMessage, messageTypes, appendBackgroundRequestLog, productsContentState, queueProductsTableRender }) {
const pending = new Set();
async function save(product) {
const id = String(product?.workload?.id ?? "");
if (!/^\d+$/.test(id) || id !== String(product.RevnId)) throw new Error("The product does not have a matching workload ID.");
if (pending.has(id)) throw new Error("Estimates are already being updated.");
const comparison = compare(product);
if (comparison.status !== "divergent") throw new Error("No forecast differences are available to update.");
pending.add(id);
try {
await ensureCurrentUser();
const updatedBy = getCurrentUserId();
if (typeof updatedBy !== "string" || !updatedBy.trim()) throw new Error("The current user ID is unavailable.");
// Keep zero months before consumption unchanged, unless they also need correcting.
const payload = comparison.rows.filter(row => row.index >= 1 || row.divergent)
.map(row => ({ year: row.year, month: row.month, amount: row.calculated, updatedBy }));
const authorizations = await requestAuthorization();
const response = await sendRuntimeMessage({ type: messageTypes.requestWorkloadForecastUpdate,
workloadId: id, payload, authorization: authorizations?.workloadActions });
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to update workload estimates.");
const amounts = new Map(payload.map(item => [`${item.year}-${item.month}`, item.amount]));
const update = workload => {
if (String(workload?.id) !== id) return workload;
return { ...workload, forecast: workload.forecast.map(month => {
const key = `${Number(month.year)}-${Number(month.month)}`;
return amounts.has(key) ? { ...month, adjustedConsumptionAmount: amounts.get(key) } : month;
}) };
};
product.workload = update(product.workload);
for (const record of productsContentState.workloadsByCustomer.values()) {
if (record.status !== "ready") continue;
record.workloads = record.workloads.map(update);
record.byId = new Map(record.workloads.map(workload => [String(workload.id), workload]));
}
for (const record of productsContentState.opptyProductsByNumber.values()) {
record.items = record.items.map(item => ({ ...item, workload: update(item.workload) }));
}
queueProductsTableRender();
return response;
} finally {
pending.delete(id);
}
}
return { save };
}

View File

@@ -0,0 +1,216 @@
function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCloseIcon, expandProducts, saveEstimates, notify, formatAmount, formatDate, environment }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
const item = document.createElement(tag);
if (className) item.className = className;
if (text !== undefined) item.textContent = text;
return item;
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving && restoreFocus) return true;
const previous = active;
active = null;
previous.modal.remove();
previous.siblings.forEach(([element, inert]) => { element.inert = inert; });
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createIndicator(product, opportunity) {
const result = compare(product);
if (result.status !== "divergent") {
const text = node("span", "opportunities-extension-projection-status", result.status === "matched" ? "OK" : "-");
text.title = result.status === "matched" ? "Monthly forecast matches the ramp calculation." : result.reason;
return text;
}
const button = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert");
button.type = "button";
button.title = `Consumption forecast differs in ${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"}. View comparison.`;
button.setAttribute("aria-label", button.title);
button.setAttribute("aria-haspopup", "dialog");
button.append(createAlertIcon());
button.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
open(product, opportunity, button);
});
return button;
}
function createOpportunityIndicator(productsResult, opportunity, isExpanded) {
const products = productsResult?.items || [];
const count = products.filter(product => compare(product).status === "divergent").length;
if (!count) return null;
const singleProduct = products.length === 1;
const indicator = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert opportunities-extension-opportunity-projection-alert");
indicator.type = "button";
indicator.title = `Consumption forecast differs in ${count} product${count === 1 ? "" : "s"}. ${singleProduct ? "View comparison." : "View products."}`;
if (singleProduct) indicator.setAttribute("aria-haspopup", "dialog");
else indicator.setAttribute("aria-expanded", String(Boolean(isExpanded)));
indicator.setAttribute("aria-label", indicator.title);
indicator.append(createAlertIcon());
indicator.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
if (singleProduct) open(products[0], opportunity, indicator);
else expandProducts(opportunity.OptyNumber);
});
return indicator;
}
function open(product, opportunity, trigger) {
const host = document.getElementById(MODAL_ID);
const result = compare(product);
if (!host || host.hidden || result.status !== "divergent") return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal opportunities-extension-projection-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-projection-title");
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-projection-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const heading = node("div", "opportunities-extension-action-form-heading");
const title = node("h2", "", "Consumption projection");
title.id = "opportunities-extension-projection-title";
heading.append(title, node("p", "", `${opportunity.OptyNumber} - ${product.WorkloadName_c || product.workload.name || product.ProdGroupName || "Workload"}`));
const dismiss = node("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.title = "Close consumption projection";
dismiss.setAttribute("aria-label", dismiss.title);
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const body = node("div", "opportunities-extension-projection-body");
const details = node("dl", "opportunities-extension-projection-context");
const currency = product.RevnAmountCurcyCode || "USD";
for (const [label, value] of [
["Customer", opportunity.CustomerAccount?.PartyUniqueName || "-"], ["Product", product.ProdGroupName || "-"],
["Annual value", formatAmount(result.annual, currency)], ["Ramp months", String(result.ramp)],
["Consumption start", formatDate(result.start)], ["First month", `${result.firstMonthDays}/${result.firstMonthTotalDays} days`]
]) details.append(node("dt", "", label), node("dd", "", value));
body.append(details);
const summary = node("p", "opportunities-extension-projection-summary", `${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"} with a difference`);
body.append(summary);
if (result.reason) body.append(node("p", "opportunities-extension-projection-notice", result.reason));
const viewport = node("div", "opportunities-extension-projection-viewport");
viewport.tabIndex = 0;
viewport.setAttribute("role", "region");
viewport.setAttribute("aria-label", "Monthly consumption comparison");
const table = node("table", "opportunities-extension-projection-table");
const caption = node("caption", "opportunities-extension-visually-hidden", `Monthly consumption comparison (${currency}). Difference is forecast minus calculated.`);
const head = node("thead"), headRow = node("tr"), tbody = node("tbody");
for (const label of ["Month", "Forecast", "Calculated", "Difference"]) {
const th = node("th", "", label); th.scope = "col"; headRow.append(th);
}
head.append(headRow);
for (const item of result.rows) {
const tr = node("tr");
tr.setAttribute("data-divergent", String(item.divergent));
const month = node("th", "", `${String(item.month).padStart(2, "0")}/${item.year}`);
month.scope = "row";
if (item.prorated) month.append(node("span", "opportunities-extension-projection-prorated", " (Prorated)"));
tr.append(month);
for (const value of [item.actual, item.calculated, item.difference]) tr.append(node("td", "", value === null ? "-" : formatAmount(value, currency)));
tbody.append(tr);
}
table.append(caption, head, tbody);
viewport.append(table);
body.append(viewport);
const errorMessage = node("p", "opportunities-extension-projection-error");
errorMessage.setAttribute("role", "alert");
errorMessage.hidden = true;
body.append(errorMessage);
const footer = node("footer", "opportunities-extension-projection-footer");
const updateButton = node("button", "opportunities-extension-projection-update", "Update estimates");
updateButton.type = "button";
footer.append(node("span", "", "Difference = forecast - calculated."), updateButton);
dialog.append(header, body, footer);
modal.append(dialog);
const siblings = Array.from(host.children).map(element => [element, element.inert]);
siblings.forEach(([element]) => { element.inert = true; });
const current = { modal, siblings, trigger, saving: false };
active = current;
async function updateEstimates() {
if (current.saving) return;
current.saving = true;
updateButton.disabled = true;
dismiss.disabled = true;
updateButton.textContent = "Updating...";
modal.setAttribute("aria-busy", "true");
errorMessage.hidden = true;
try {
await saveEstimates(product);
current.saving = false;
if (active === current) close();
notify("Workload estimates updated successfully.");
} catch (error) {
const message = error.message || "Unable to update workload estimates.";
if (active === current) {
errorMessage.textContent = message;
errorMessage.hidden = false;
}
notify(message, true);
} finally {
current.saving = false;
updateButton.disabled = false;
dismiss.disabled = false;
updateButton.textContent = "Update estimates";
modal.setAttribute("aria-busy", "false");
}
}
updateButton.addEventListener("click", () => {
if (current.saving || current.confirmation) return;
const overlay = node("div", "opportunities-extension-action-form-modal");
const confirmation = node("section", "opportunities-extension-action-form-dialog opportunities-extension-projection-confirm");
confirmation.setAttribute("role", "alertdialog");
confirmation.setAttribute("aria-modal", "true");
confirmation.setAttribute("aria-labelledby", "projection-confirm-title");
confirmation.setAttribute("aria-describedby", "projection-confirm-message");
const title = node("h2", "", "Update estimates");
title.id = "projection-confirm-title";
const message = node("p", "", "The estimates will be updated directly in the SPA using the values in the Calculated column. Would you like to confirm the update?");
message.id = "projection-confirm-message";
const actions = node("div", "opportunities-extension-projection-confirm-actions");
const cancel = node("button", "opportunities-extension-projection-cancel", "Cancel");
const confirm = node("button", "opportunities-extension-projection-update", "Confirm update");
cancel.type = confirm.type = "button";
function dismissConfirmation() {
overlay.remove();
current.confirmation = null;
dialog.inert = false;
updateButton.focus();
}
cancel.addEventListener("click", dismissConfirmation);
confirm.addEventListener("click", () => {
if (current.confirmation !== overlay || active !== current) return;
dismissConfirmation();
return updateEstimates();
});
overlay.addEventListener("keydown", event => {
if (event.key === "Escape") {
event.preventDefault(); event.stopPropagation(); dismissConfirmation();
} else if (event.key === "Tab") {
event.preventDefault(); event.stopPropagation();
(document.activeElement === cancel ? confirm : cancel).focus();
}
});
actions.append(cancel, confirm);
confirmation.append(title, message, actions);
overlay.append(confirmation);
current.confirmation = overlay;
dialog.inert = true;
modal.append(overlay);
cancel.focus();
});
host.append(modal);
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const controls = [dismiss, viewport, updateButton].filter(control => !control.disabled);
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
}
});
dismiss.focus();
}
return { createIndicator, createOpportunityIndicator, open, close };
}

View File

@@ -0,0 +1,52 @@
.opportunities-extension-products-table th:last-child { width: 90px; padding-left: 6px; padding-right: 6px; white-space: nowrap; text-align: center; }
.opportunities-extension-products-table .opportunities-extension-projection-cell { text-align: center; vertical-align: middle; }
.opportunities-extension-projection-alert { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 0; color: #976000; width: 30px; height: 30px; flex: 0 0 30px; vertical-align: middle; }
.opportunities-extension-projection-alert svg { display: block; flex: none; width: 19px; height: 19px; }
.opportunities-extension-cell-actions > .opportunities-extension-opportunity-projection-alert { width: 20px; height: 20px; flex-basis: 20px; }
.opportunities-extension-projection-alert:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-action-form-dialog.opportunities-extension-projection-dialog { width: min(850px, 100%); display: flex; flex-direction: column; overflow: hidden; }
.opportunities-extension-projection-dialog > header { flex: 0 0 auto; padding: 18px 20px; }
.opportunities-extension-projection-dialog .opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-projection-body { min-height: 0; overflow: auto; padding: 16px 20px 0; }
.opportunities-extension-projection-context { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content minmax(0, 1fr); gap: 8px 12px; margin: 0 0 16px; font-size: 12px; }
.opportunities-extension-projection-context dt { font-weight: 600; }
.opportunities-extension-projection-context dd { margin: 0; overflow-wrap: anywhere; }
.opportunities-extension-projection-summary { font-size: 13px; font-weight: 600; color: #815400; margin: 0 0 12px; }
.opportunities-extension-projection-notice { font-size: 12px; }
.opportunities-extension-projection-viewport { overflow-x: auto; }
.opportunities-extension-projection-table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 9px 12px; border-bottom: 1px solid #dedbd7; text-align: right; white-space: nowrap; }
.opportunities-extension-projection-table th:first-child { text-align: left; }
.opportunities-extension-projection-table thead th { background: #f5f4f2; font-weight: 600; }
.opportunities-extension-projection-table tbody th { font-weight: 400; }
.opportunities-extension-projection-table tr[data-divergent="true"] { background: #fff7e5; }
.opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #815400; font-weight: 700; }
.opportunities-extension-projection-prorated { display: inline; color: #625d58; font-size: 10px; }
.opportunities-extension-projection-footer { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0; padding: 14px 20px; font-size: 11px; color: #625d58; }
.opportunities-extension-projection-update { flex: none; padding: 8px 12px; border: 1px solid #006879; border-radius: 4px; background: #006879; color: #fff; font: inherit; font-size: 12px; cursor: pointer; }
.opportunities-extension-projection-update:hover:not(:disabled) { background: #005563; }
.opportunities-extension-projection-update:disabled { opacity: .6; cursor: wait; }
.opportunities-extension-projection-update:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-projection-confirm { padding: 20px; }
.opportunities-extension-projection-confirm h2 { margin: 0 0 12px; font-size: 18px; }
.opportunities-extension-projection-confirm p { margin: 0 0 20px; font-size: 13px; line-height: 1.5; }
.opportunities-extension-projection-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.opportunities-extension-projection-cancel { padding: 8px 12px; border: 1px solid #8b8580; border-radius: 4px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; }
.opportunities-extension-projection-error { color: #b3261e; font-size: 12px; overflow-wrap: anywhere; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-error { color: #ffb4ab; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-alert,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-summary,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #ffd080; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table thead th { background: #34312e; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table td { border-color: #504b46; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table tr[data-divergent="true"] { background: #3e3425; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-prorated,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-footer { color: #c9c3bc; }
@media (max-width: 600px) {
.opportunities-extension-projection-context { grid-template-columns: max-content minmax(0, 1fr); }
.opportunities-extension-projection-dialog > header { padding: 14px 12px; }
.opportunities-extension-projection-body { padding: 12px 12px 0; }
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 8px 6px; }
.opportunities-extension-projection-footer { padding: 12px; }
}

View File

@@ -0,0 +1,63 @@
function createRampProjection() {
function number(value) {
if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") return null;
const result = Number(value);
return Number.isFinite(result) ? result : null;
}
function startDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/.exec(String(value ?? ""));
if (!match) return null;
const [, year, month, day] = match.map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
return { year, month, day, days: new Date(Date.UTC(year, month, 0)).getUTCDate() };
}
function monthlyAmount(annual, ramp, month) {
if (month < 1) return 0;
// calculadora rampa.xlsx, rampas!D6:P17: exact values, before currency rounding.
return (2 * annual / (24 - ramp)) * Math.min(month / (ramp + 1), 1);
}
function cents(value) { return Math.round((value + Number.EPSILON * Math.abs(value)) * 100); }
function compare(product) {
const workload = product?.workload;
const unavailable = reason => ({ status: "unavailable", reason, rows: [], divergentCount: 0 });
if (!workload) return unavailable("Workload data is not available for this product.");
const annual = number(workload.adjustedACR);
const ramp = number(product.RampMonths_c);
const start = startDate(workload.consumptionStartDate);
if (annual === null || annual < 0) return unavailable("Annual value (adjustedACR) is missing or invalid.");
if (ramp === null || !Number.isInteger(ramp) || ramp < 0 || ramp > 11) {
return unavailable("RampMonths_c must be an integer from 0 to 11, as covered by the reference workbook.");
}
if (!start) return unavailable("Consumption start date is missing or invalid.");
if (!Array.isArray(workload.forecast) || !workload.forecast.length) return unavailable("Monthly forecast data is not available.");
const periods = new Set();
const rows = workload.forecast.map(forecast => {
const year = number(forecast?.year), month = number(forecast?.month);
const actual = number(forecast?.adjustedConsumptionAmount);
if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12) return null;
const key = `${year}-${String(month).padStart(2, "0")}`;
if (periods.has(key)) return null;
periods.add(key);
const index = (year - start.year) * 12 + month - start.month + 1;
const prorated = index === 1 && start.day > 1;
const exact = monthlyAmount(annual, ramp, index) * (index === 1 ? (start.days - start.day + 1) / start.days : 1);
const calculatedCents = cents(exact);
const actualCents = actual === null ? null : cents(actual);
if (!Number.isSafeInteger(calculatedCents) || (actualCents !== null && !Number.isSafeInteger(actualCents))) return null;
const differenceCents = actualCents === null ? null : actualCents - calculatedCents;
return { key, year, month, index, prorated, actual: actualCents === null ? null : actualCents / 100,
calculated: calculatedCents / 100, difference: differenceCents === null ? null : differenceCents / 100,
divergent: differenceCents !== null && differenceCents !== 0 };
});
if (rows.some(row => row === null)) return unavailable("Forecast contains invalid or duplicate months, or values outside the supported numeric range.");
rows.sort((a, b) => a.key.localeCompare(b.key));
const divergentCount = rows.filter(row => row.divergent).length;
const missingCount = rows.filter(row => row.actual === null).length;
return { status: divergentCount ? "divergent" : missingCount ? "unavailable" : "matched",
reason: missingCount ? "Some months have no adjusted consumption amount." : "", rows, divergentCount, missingCount,
annual, ramp, start: `${start.year}-${String(start.month).padStart(2, "0")}-${String(start.day).padStart(2, "0")}`,
firstMonthDays: start.days - start.day + 1, firstMonthTotalDays: start.days };
}
return { compare, monthlyAmount };
}

View File

@@ -0,0 +1,51 @@
function createWorkloadsCache({ fetchAllWorkloads, storageLocalGet, storageLocalSet }) {
const KEY = "opportunitiesExtension.workloadsCache.v1";
let cachePromise = null;
let writePromise = Promise.resolve();
let generation = 0;
const inFlight = new Map();
function read() {
if (!cachePromise) cachePromise = storageLocalGet(KEY)
.then(result => result?.[KEY] && typeof result[KEY] === "object" && !Array.isArray(result[KEY]) ? result[KEY] : {})
.catch(() => ({}));
return cachePromise;
}
function write(cache) {
writePromise = writePromise.catch(() => {}).then(() => storageLocalSet({ [KEY]: cache }));
return writePromise;
}
async function invalidate() {
generation += 1;
inFlight.clear();
cachePromise = Promise.resolve({});
await write({});
}
async function get(customerId, token, maxAgeMs, principal) {
const scope = typeof principal === "string" ? principal.trim().toLowerCase() : "";
// isMyWorkload is user-specific. Never persist a result without a known principal.
if (!scope) return { ...await fetchAllWorkloads(customerId, token), fromCache: false, cachedAt: Date.now() };
const key = JSON.stringify([scope, customerId]);
const currentGeneration = generation;
const cache = await read();
if (generation !== currentGeneration) return get(customerId, token, maxAgeMs, principal);
const entry = cache[key];
const age = Date.now() - Number(entry?.fetchedAt);
if (Array.isArray(entry?.items) && age >= 0 && age < maxAgeMs) {
return { workloads: entry.items, cachedAt: Number(entry.fetchedAt), fromCache: true, debugRequests: [] };
}
if (!inFlight.has(key)) {
const flight = fetchAllWorkloads(customerId, token).then(async result => {
const cachedAt = Date.now();
if (generation === currentGeneration) {
cache[key] = { fetchedAt: cachedAt, items: result.workloads };
await write(cache).catch(() => {});
}
return { ...result, cachedAt, fromCache: false };
}).finally(() => { if (inFlight.get(key) === flight) inFlight.delete(key); });
inFlight.set(key, flight);
}
return inFlight.get(key);
}
return { get, invalidate };
}

View File

@@ -0,0 +1,78 @@
function createProductWorkloads({ productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, getPrincipal, queueProductsTableRender }) {
let cycle = null;
function reset() {
cycle = null;
productsContentState.workloadCustomerIds = [];
productsContentState.workloadsByCustomer = new Map();
}
function isCurrent(current) {
return cycle === current && current.requestId === productsContentState.currentProductsRequestId
&& current.version === opportunitiesQueryState.periodRequestVersion;
}
function joinProducts(optyNumber) {
if (!cycle || !isCurrent(cycle)) return;
const key = String(optyNumber);
const products = productsContentState.opptyProductsByNumber.get(key);
if (!products) return;
const customerId = cycle.customerByOpportunity.get(key);
const record = productsContentState.workloadsByCustomer.get(customerId);
products.items = products.items.map(product => ({ ...product,
workload: record?.status === "ready" ? record.byId.get(String(product.RevnId ?? "")) || null : null }));
}
async function load(opportunities, requestId) {
reset();
const current = { requestId, version: opportunitiesQueryState.periodRequestVersion,
customerByOpportunity: new Map(), opportunitiesByCustomer: new Map() };
cycle = current;
for (const opportunity of opportunities) {
const customerId = String(opportunity?.CustomerAccount?.PartyId ?? "").trim();
if (!/^\d+$/.test(customerId)) continue;
if (!current.opportunitiesByCustomer.has(customerId)) current.opportunitiesByCustomer.set(customerId, new Set());
if (opportunity.OptyNumber) {
const number = String(opportunity.OptyNumber);
current.customerByOpportunity.set(number, customerId);
current.opportunitiesByCustomer.get(customerId).add(number);
}
}
const ids = [...current.opportunitiesByCustomer.keys()];
productsContentState.workloadCustomerIds = ids;
const records = productsContentState.workloadsByCustomer;
for (const id of ids) records.set(id, { status: "loading", workloads: [], byId: new Map() });
if (!ids.length || !isCurrent(current)) return;
const principal = getPrincipal();
const cacheMaxAgeMs = preferencesState.opptyProductsCacheHours * 60 * 60 * 1000;
try {
const authorizations = await requestConsumerTokenServiceOnce();
if (!isCurrent(current)) return;
let next = 0;
const worker = async () => {
while (isCurrent(current) && next < ids.length) {
const customerId = ids[next++];
const record = records.get(customerId);
try {
const result = await sendRuntimeMessage({ type: messageTypes.requestWorkloads, customerId,
authorization: authorizations?.workloadActions, cacheMaxAgeMs, principal });
if (!isCurrent(current)) return;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok || !Array.isArray(result.workloads)) throw new Error(result?.error || "Unable to load workloads.");
Object.assign(record, { status: "ready", workloads: result.workloads,
byId: new Map(result.workloads.map(workload => [String(workload.id), workload])),
fromCache: Boolean(result.fromCache), cachedAt: Number(result.cachedAt) || 0 });
} catch (error) {
if (!isCurrent(current)) return;
record.status = "error";
record.error = error.message || "Unable to load workloads.";
}
for (const number of current.opportunitiesByCustomer.get(customerId)) joinProducts(number);
queueProductsTableRender();
}
};
await Promise.all(Array.from({ length: Math.min(4, ids.length) }, worker));
} catch (error) {
if (!isCurrent(current)) return;
for (const record of records.values()) { record.status = "error"; record.error = error.message || "Unable to load workloads."; }
}
}
return { load, joinProducts, reset };
}

View File

@@ -29,6 +29,19 @@ function createPlatformBackgroundApi({
});
}
function tabsUpdate(tabId, details) {
if (runtimeApi.tabs.update.length <= 2) {
return runtimeApi.tabs.update(tabId, details);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.update(tabId, details, tab => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) reject(new Error(lastError.message));
else resolve(tab);
});
});
}
function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId);
@@ -186,5 +199,5 @@ function createPlatformBackgroundApi({
);
}
return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues };
return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsUpdate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues };
}

View File

@@ -1,9 +1,13 @@
function createPlatformContentMessages({
messageTypes,
requestCache,
environment
}) {
const { browser, chrome } = environment;
function sendRuntimeMessage(message) {
return requestCache ? requestCache.send(message, sendUncached) : sendUncached(message);
}
function sendUncached(message) {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {

View File

@@ -11,7 +11,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
] },
keywords: null,
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "_actions", "Title",
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "CustomerDeadline_c", "PrimaryPillarNew_c", "_actions", "Title",
"Owner.PersonFirstName", "Owner.PersonLastName", "Owner.PrimaryEmail.EmailAddress", "DoNotCallFlag", "DoNotEmailFlag"],
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c",
@@ -29,7 +29,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
};
const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url,
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset, attempt } });
headers: { ...headers }, body: JSON.stringify(payload, null, 2), metadata: { offset, attempt } });
try {
const response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)

View File

@@ -24,7 +24,7 @@ function createFusionOpportunities({
url: OPPORTUNITIES_QUERY_URL,
headers: {
Accept: "application/json",
Authorization: `Bearer ${maskToken(token)}`,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Origin: "https://eeho.fa.us2.oraclecloud.com",
Preference: "transient"
@@ -102,19 +102,21 @@ function createFusionOpportunities({
op: "$and",
criteria: [
{
op: "$or",
criteria: Array.from(getOpportunityTypeValues(), (value) => ({
op: "$eq",
attribute: "RecordSet",
value
}))
op: "$and",
criteria: [{
op: "$or",
criteria: Array.from(getOpportunityTypeValues(), (value) => ({
op: "$eq",
attribute: "RecordSet",
value
}))
}]
},
{
op: "$wi",
attribute: "EffectiveDate",
value1: dateRange.startCloseDate,
value2: dateRange.endCloseDate,
dynamicDate: false
value1: new Date(dateRange.startCloseDate).toISOString().replace(/Z$/, "+0000"),
value2: new Date(dateRange.endCloseDate).toISOString().replace(/Z$/, "+0000")
}
]
},
@@ -150,9 +152,11 @@ function createFusionOpportunities({
"OptyId",
"CustomerAccount.PartyUniqueName",
"CustomerAccount.PartyId",
"CustomerAccount.PartyNumber"
"CustomerAccount.PartyNumber",
"CustomerAccount.Region_c",
"CustomerAccount.PrimaryAddress.CountryCode"
],
sort: [],
sort: null,
language: "en",
skipInValidFields: true,
skipHiddenFromUIFields: true,

View File

@@ -0,0 +1,181 @@
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, runtimeApi }) {
const resourceRequests = new Map();
let resourceGeneration = 0;
const resourceCacheKey = "opportunitiesExtension.resourceUsers.v1";
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/";
const catalog = base + "crmRestApiResources/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/11.13.18.05/Catalogue_c";
const resourceUsers = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers";
const endpoint = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/serviceRequests";
function numericId(value, name) {
if (!/^\d+$/.test(String(value ?? "")) || !Number.isSafeInteger(Number(value)) || Number(value) <= 0) throw new Error(`Missing or invalid ${name}.`);
return Number(value);
}
function text(value, name) {
if (typeof value !== "string" || !value.trim()) throw new Error(`Missing ${name}.`);
return value.trim();
}
function dateOnly(value, name) {
const date = text(value, name);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(`${date}T00:00:00Z`))
|| new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date) throw new Error(`Invalid ${name}.`);
return date;
}
function payloadFor(input) {
const payload = {};
for (const name of ["AccountPartyId", "OpportunityName_Id_c", "ServiceNew_Id_c"]) payload[name] = numericId(input?.[name], name);
for (const name of ["Title", "RegionText_c", "ProblemDescription", "Language_c", "CustomerDeadline_c", "TechPlatformLookUp_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(input?.[name], name);
dateOnly(payload.CustomerDeadline_c, "Customer deadline");
return { ...payload, SvcQuestionsCollection_c: [], SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
}
async function requestResourceUser(message, debugRequests) {
try {
const result = await getResourceUser(message);
debugRequests.push(...result.debugRequests);
return numericId(result.resource.resourcePartyId, "resourcePartyId");
} catch (error) { debugRequests.push(...(error.debugRequests || [])); throw error; }
}
async function getResourceUser(message) {
const emailAddress = text(message.emailAddress, "current user email").toLowerCase();
const duration = Number(message.cacheMaxAgeMs) > 0 ? Math.min(Number(message.cacheMaxAgeMs), 168 * 3600000) : 24 * 3600000;
const fresh = timestamp => Number.isFinite(timestamp) && Date.now() >= timestamp && Date.now() - timestamp < duration;
const current = resourceRequests.get(emailAddress);
if (current && (current.fetchedAt === null || fresh(current.fetchedAt))) return { ok: true, resource: await current.promise, debugRequests: [] };
const generation = resourceGeneration;
const record = { promise: null, fetchedAt: null };
const debugRequests = [];
const pending = (async () => {
try {
const stored = await runtimeApi?.storage?.session?.get(resourceCacheKey);
const cached = stored?.[resourceCacheKey];
if (cached?.emailAddress === emailAddress && cached.resource?.resourcePartyId && fresh(cached.fetchedAt)) {
record.fetchedAt = cached.fetchedAt;
return cached.resource;
}
} catch { /* Session storage is optional; retain the in-memory cache. */ }
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestResourceUser", debugRequests);
numericId(resource?.resourcePartyId, "resourcePartyId");
record.fetchedAt = Date.now();
if (generation === resourceGeneration) {
try { await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: { emailAddress, resource, fetchedAt: record.fetchedAt } }); } catch { /* Keep the successful response in memory. */ }
}
return resource;
})();
record.promise = pending;
resourceRequests.set(emailAddress, record);
try { return { ok: true, resource: await pending, debugRequests }; }
catch (error) { if (resourceRequests.get(emailAddress) === record) resourceRequests.delete(emailAddress); error.debugRequests = debugRequests; throw error; }
}
async function requestPostInternalServiceRequest(message) {
const debugRequests = [];
try {
const source = message.payload;
const parentSrId = numericId(source?.ServiceRequest_Id_SRToInternalSR, "parent SrId");
const payload = {
Title: text(source?.Title, "Title"),
ServiceNew_Id_c: numericId(source?.ServiceNew_Id_c, "ServiceNew_Id_c"),
ProblemDescription: text(source?.ProblemDescription, "Description"),
CustomerDeadline_c: dateOnly(source?.CustomerDeadline_c, "Customer deadline"),
TechPlatformLookUp_c: text(source?.TechPlatformLookUp_c, "Platform"),
InternalDeadline_c: `${dateOnly(source?.InternalDeadline_c, "Internal Deadline")}T00:00:00.000Z`,
SvcQuestionsCollection_c: []
};
const parent = await requestSRInfo(parentSrId, debugRequests);
payload.ServiceRequest_Id_SRToInternalSR = String(parentSrId);
for (const name of ["AccountPartyId", "OpportunityName_Id_c"]) payload[name] = numericId(parent[name], name);
for (const name of ["OpportunityName_c", "RegionText_c", "SvcHealth_c", "Language_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(parent[name], name);
payload.SecondaryPillarNew_c = parent.SecondaryPillarNew_c == null ? null : text(parent.SecondaryPillarNew_c, "SecondaryPillarNew_c");
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const result = await fetchJson(url, { method: "POST", credentials: "include",
headers: await internalServiceRequestHeaders(url),
body: JSON.stringify(payload)
}, "requestPostInternalServiceRequest", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestSRInfo(parentSrId, debugRequests) {
const url = new URL("https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests");
url.searchParams.set("fields", "PrimaryPillarNew_c,SecondaryPillarNew_c,SvcHealth_c,SrNumber,AccountPartyId,CountryText_c,Language_c,OpportunityName_Id_c,OpportunityName_c,RegionText_c,ServiceNew_Id_c");
url.searchParams.set("onlyData", "true");
url.searchParams.set("orderBy", "LastUpdateDate:desc");
url.searchParams.set("q", `SrId=${parentSrId}`);
const result = await fetchJson(url.href, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestSRInfo", debugRequests);
if (!Array.isArray(result?.items) || result.items.length !== 1 || !result.items[0]
|| (result.items[0].SrId != null && String(result.items[0].SrId) !== String(parentSrId))) {
throw new Error("requestSRInfo did not return the selected parent Service Request.");
}
return result.items[0];
}
async function requestPostServiceRequest(message) {
const debugRequests = [];
try {
if (message.phase !== "prepare" && message.phase !== "confirm") throw new Error("Service Request confirmation is required.");
const payload = payloadFor(message.payload);
if (message.phase === "prepare") {
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
const sharedHeaders = await previewTokenHeaders(catalog);
return { ok: true, payload, debugRequests, headers: {
requestSrTokenCatalog: sharedHeaders,
requestPostServiceRequest: { ...sharedHeaders, "vb-proxy-header-x-oracle-ectoken": "(generated after confirmation)" }
} };
}
payload.ReportedByPartyId = numericId(message.payload?.ReportedByPartyId, "ReportedByPartyId");
const headers = await requestCreationHeaders(message.catalogId, endpoint, debugRequests);
const result = await fetchJson(endpoint, {
method: "POST", credentials: "include", headers,
body: JSON.stringify(payload)
}, "requestPostServiceRequest", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
annotateSigningRequest(debugRequests);
}
}
async function requestCreationHeaders(id, target, debugRequests) {
if (id == null) {
const result = await fetchJson(`${catalog}?onlyData=true&fields=Id&limit=1`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestSrCatalog", debugRequests);
id = result?.items?.[0]?.Id;
}
id = numericId(id, "Catalogue Id");
const tokenUrl = `${catalog}/${id}`;
let catalogHeaders = await tokenHeaders(tokenUrl, target);
const requestToken = () => fetchJson(tokenUrl, {
method: "POST", credentials: "include", headers: catalogHeaders, body: JSON.stringify({ name: "getJwtToken" })
}, "requestSrTokenCatalog", debugRequests);
let token;
try {
token = await requestToken();
} catch (error) {
if (error.httpStatus !== 403) throw error;
const refreshed = await previewTokenHeaders(tokenUrl);
if (!refreshed["x-appbuilder-client-id"] || refreshed["x-appbuilder-client-id"] === catalogHeaders["x-appbuilder-client-id"]) throw error;
catalogHeaders = refreshed;
token = await requestToken();
}
if (debugRequests.at(-1)?.httpStatus !== 200 || typeof token?.result !== "string" || !token.result.trim()) {
throw new Error("requestSrTokenCatalog did not return HTTP 200 with a token.");
}
return { ...catalogHeaders, "vb-proxy-header-x-oracle-ectoken": token.result.trim() };
}
function annotateSigningRequest(debugRequests) {
for (const entry of debugRequests) {
if (entry.name === "requestSrTokenCatalog") {
entry.metadata.appBuilderClientId = entry.requestHeaders?.["x-appbuilder-client-id"] ? "Observed in SeaaS requests" : "Not observed in SeaaS requests";
}
}
}
async function invalidateResourceUser() {
resourceGeneration++;
resourceRequests.clear();
await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: null });
}
return { requestPostServiceRequest, requestPostInternalServiceRequest, getResourceUser, invalidateResourceUser };
}

View File

@@ -1,4 +1,146 @@
function createFusionSrDetails({ fetchJson }) {
function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFirefoxCookieStoreTabs, cookiesGetAll, cookieChanges, wait }) {
const sessions = new Map();
function cookiePart(cookie) {
if (!cookie) return null;
const match = /^ORA_OCIS_CG_SESSION_idcs-([a-zA-Z0-9-]+)_comcipapic-oalprod\.integration\.ocp\.oraclecloud\.com_([12])$/.exec(cookie.name);
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookieDomain = cookie.domain?.replace(/^\./, "");
if (!match || (cookieDomain && cookieDomain !== domain && !domain.endsWith(`.${cookieDomain}`))) return null;
return { key: `${match[1]}|${cookie.path || "/"}|${cookie.storeId || ""}`, part: match[2],
valid: Boolean(cookie.value) && (cookie.expirationDate == null || cookie.expirationDate > Date.now() / 1000) };
}
function watchSession(session, sender) {
const pairs = new Map();
let available;
session.cookieReady = new Promise(resolve => { available = resolve; });
const listener = change => {
const cookie = change.cookie;
const part = cookiePart(cookie);
if (!part) return;
if (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId) return;
if (!pairs.has(part.key)) pairs.set(part.key, new Map());
const pair = pairs.get(part.key);
if (change.removed || !part.valid) pair.delete(part.part);
else pair.set(part.part, cookie);
if (pair.size === 2 && [...pair.values()].every(item => cookiePart(item)?.valid)) available(new Set([part.key]));
session.wake?.();
};
cookieChanges?.addListener(listener);
session.unwatch = () => cookieChanges?.removeListener(listener);
}
function waitForSessionCookies(session, sender) {
if (session.cookieWait) return session.cookieWait;
session.cookieWait = (async () => {
const deadline = Date.now() + 1000;
for (let attempt = 0; attempt < 5; attempt += 1) {
// Install the wake-up before reading so a renewal during getAll is not missed.
const changed = new Promise(resolve => { session.wake = resolve; });
const current = await sessionCookies(sender);
const renewed = [...current].some(pair => !session.baseline.has(pair));
if (renewed || Date.now() >= deadline) {
session.baseline = current;
return current;
}
await Promise.race([changed, wait(250)]);
}
return sessionCookies(sender);
})().finally(() => { session.cookieWait = null; session.wake = null; });
return session.cookieWait;
}
async function sessionCookies(sender) {
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const details = { domain };
if (sender?.tab?.cookieStoreId) details.storeId = sender.tab.cookieStoreId;
try {
const groups = new Map();
for (const cookie of await cookiesGetAll(details)) {
const part = cookiePart(cookie);
if (!part?.valid) continue;
if (!groups.has(part.key)) groups.set(part.key, {});
groups.get(part.key)[part.part] = [cookie.name, cookie.value, cookie.expirationDate ?? null];
}
// Fingerprints remain in memory only; the browser sends the actual Cookie header.
return new Set([...groups.values()].filter(pair => pair[1] && pair[2]).map(pair => JSON.stringify([pair[1], pair[2]])));
} catch { return new Set(); }
}
async function requestLookupWithSession(operation, message, sender, initialize = false) {
const existingCookies = initialize ? await sessionCookies(sender) : null;
if (existingCookies?.size) return { ok: true, debugRequests: [] };
const logs = [];
const query = async () => {
try {
const result = await operation(message);
logs.push(...(result.debugRequests || []));
return { ...result, debugRequests: logs };
} catch (error) {
logs.push(...(error.debugRequests || []));
error.debugRequests = logs;
throw error;
}
};
if (!initialize) {
try { return await query(); } catch (error) { if (error.httpStatus !== 401) throw error; }
}
const key = sender?.tab?.cookieStoreId || "default";
let session = sessions.get(key);
if (!session) {
const details = {
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
active: false
};
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
if (supportsFirefoxCookieStoreTabs() && sender?.tab?.cookieStoreId) details.cookieStoreId = sender.tab.cookieStoreId;
session = { users: 0, closed: null, baseline: null };
watchSession(session, sender);
session.tab = (async () => {
session.baseline = existingCookies || await sessionCookies(sender);
return tabsCreate(details);
})();
sessions.set(key, session);
}
session.users += 1;
let tab;
try {
tab = await session.tab;
const deadline = Date.now() + 60000;
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
const pendingCookies = initialize && attempt === 0
? sessionCookies(sender) : waitForSessionCookies(session, sender);
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
if (initialize) {
// Proactive preparation observes cookies only; API requests belong to the form.
if (!cookies.size) continue;
await closeSessionTab();
return { ok: true, debugRequests: [] };
}
try {
const result = await query();
// Any successful lookup confirms the shared session; stop the page immediately.
await closeSessionTab();
return result;
} catch (error) {
if (error.httpStatus !== 401) throw error;
}
}
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
} catch (error) {
error.debugRequests = logs;
throw error;
} finally {
session.users -= 1;
if (session.users === 0) {
sessions.delete(key);
session.unwatch();
await closeSessionTab();
}
}
function closeSessionTab() {
if (!session.closed && Number.isInteger(tab?.id)) {
session.closed = tabsRemove(tab.id).catch(() => {});
}
return session.closed;
}
}
async function requestInternalServiceRequest(message) {
const debugRequests = [];
try {
@@ -7,10 +149,14 @@ function createFusionSrDetails({ fetchJson }) {
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
const result = await fetchJson(url, { method: "GET", credentials: "include",
const query = async () => {
const result = await fetchJson(url, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, "requestInternalServiceRequest", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
}, "requestInternalServiceRequest", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
return result;
};
const result = await query();
const items = result.items.flatMap(item => {
const children = item.ServiceRequest_SRToInternalSR_Tgt;
if (children == null) return [];
@@ -23,5 +169,60 @@ function createFusionSrDetails({ fetchJson }) {
throw error;
}
}
return { requestInternalServiceRequest };
async function requestLookup(lookupType, requestName) {
const debugRequests = [];
try {
const url = `https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/lookups?lookupType=${lookupType}&type=STANDARD`;
const result = await fetchJson(url, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, requestName, debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => typeof item?.code !== "string" || typeof item?.name !== "string")) throw new Error(`Invalid ${requestName} response.`);
const items = result.items.filter(item => item.enabledFlag !== "N")
.sort((a, b) => (Number(a.displaySequence) || 0) - (Number(b.displaySequence) || 0))
.map(({ code, name }) => ({ code, name }));
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestSrLookupOpportunityBusiness(message) {
const debugRequests = [];
try {
if (typeof message.emailAddress !== "string" || !message.emailAddress.trim()) throw new Error("Current user email is unavailable.");
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/services"
+ `?emailAddress=${encodeURIComponent(message.emailAddress.trim())}&salesObject=OPPORTUNITY&serviceType=BUSINESS`;
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" } }, "requestSrLookupOpportunityBusiness", debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => !/^\d+$/.test(String(item?.Id ?? "")) || typeof item?.RecordName !== "string")) throw new Error("Invalid Services response.");
return { ok: true, items: result.items.map(({ Id, RecordName, Abstract_c }) => ({ Id: String(Id), RecordName, Abstract_c: Abstract_c == null ? "" : String(Abstract_c) })), debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestInternalSrOptions(message, services = false) {
const debugRequests = [];
const name = services ? "requestInternalSrServices" : "requestLookupTypePlatform";
try {
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetSeaaSOKECustomRestAPI/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/";
let path = "lookups?type=STANDARD&lookupType=SENGG_TECH_PLATFORM";
if (services) {
const id = String(message.parentServiceId ?? "");
if (!/^\d+$/.test(id)) throw new Error("A numeric parent SrId is required.");
if (typeof message.emailAddress !== "string" || !message.emailAddress.trim()) throw new Error("Current user email is unavailable.");
path = `services?emailAddress=${encodeURIComponent(message.emailAddress.trim())}&parentServiceId=${encodeURIComponent(id)}&serviceType=INTERNAL`;
}
const result = await fetchJson(base + path, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, name, debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => services
? !/^\d+$/.test(String(item?.Id ?? "")) || typeof item?.RecordName !== "string"
: typeof item?.code !== "string" || typeof item?.name !== "string")) throw new Error(`Invalid ${name} response.`);
const items = services
? result.items.map(item => ({ Id: String(item.Id), RecordName: item.RecordName, Description_c: String(item.Description_c ?? "") }))
: result.items.filter(item => item.enabledFlag !== "N").map(({ code, name }) => ({ code, name }));
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
return { requestLookupWithSession, requestInternalServiceRequest, requestSrLookupOpportunityBusiness,
requestLookupTypePlatform: () => requestInternalSrOptions({}),
requestInternalSrServices: message => requestInternalSrOptions(message, true),
prepareSession: sender => requestLookupWithSession(null, {}, sender, true),
requestSrLookupPrimaryPillar: () => requestLookup("SENGG_PRIMARY_PILLAR", "requestSrLookupPrimaryPillar"),
requestSrLookupLanguage: () => requestLookup("SENGG_LANGUAGE", "requestSrLookupLanguage"),
requestSrLookupTechPlatform: () => requestLookup("SENGG_TECH_PLATFORM", "requestSrLookupTechPlatform") };
}

View File

@@ -0,0 +1,118 @@
function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
const endpoint = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails";
async function requestWeekDetails(message) {
const debugRequests = [];
try {
const date = dates.toApi(message.dateReference);
const items = await fetchJson(`${endpoint}?dateValue=${encodeURIComponent(date)}`, {
method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" }
}, "requestWeekDetails", debugRequests);
if (!Array.isArray(items) || items.length !== 7 || items.some(item => !dates.fromApi(item?.Date) || !dates.fromApi(item?.EndDate))
|| new Set(items.map(item => item.Date)).size !== 7) throw new Error("Invalid week details response.");
return { ok: true, items, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
async function requestTimeEntries(message) {
const debugRequests = [];
try {
if (!/^\d+$/.test(String(message.weekId)) || Number(message.weekId) <= 0) throw new Error("Missing or invalid WeekId.");
const resource = await getResourceUser(message);
debugRequests.push(...resource.debugRequests);
const url = endpoint.replace(/\/weekDetails$/, "") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(message.weekId)}`;
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestTimeEntries", debugRequests);
if (!Array.isArray(items)) throw new Error("Invalid time entries response.");
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestTimeEntriesDelete(message) {
const debugRequests = [];
try {
if (!/^[1-9]\d*$/.test(String(message.id))) throw new Error("Missing or invalid time entry ID.");
const url = endpoint.replace(/\/weekDetails$/, "") + `/${message.id}`;
const headers = await saveHeaders(url);
const result = await fetchJson(url, { method: "DELETE", credentials: "include", headers },
"requestTimeEntriesDelete", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestTimeEntriesSave(message) {
const debugRequests = [];
try {
if (!["POST", "PUT"].includes(message.method)) throw new Error("Invalid time entry save method.");
if (!Array.isArray(message.items) || !message.items.length) throw new Error("No time entries to save.");
const hours = ["saturdayTaskHours", "sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours"];
for (const item of message.items) {
if (!item || !["SERVICE", "NON_SERVICE"].includes(item.tag) || !item.taskType
|| (item.tag === "SERVICE" && (!/^SR\d+$/.test(item.srNumber) || !item.activityCode))
|| !/^\d+$/.test(String(item.weekId)) || Number(item.weekId) <= 0
|| hours.some(key => typeof item[key] !== "number" || !Number.isFinite(item[key]) || item[key] < 0 || item[key] > 24)
|| !hours.some(key => item[key] > 0)
|| (message.method === "PUT" && (!/^\d+$/.test(String(item.id)) || Number(item.id) <= 0))) throw new Error("Invalid or incomplete time entry.");
}
const resource = await getResourceUser(message);
debugRequests.push(...resource.debugRequests);
const items = message.items.map(item => ({
...(message.method === "PUT" ? { id: item.id } : {}),
srNumber: item.tag === "NON_SERVICE" ? "non-service-sr" : item.srNumber,
activityCode: item.activityCode || "", taskType: item.taskType, deliveryType: item.deliveryType || "",
...Object.fromEntries(hours.map(key => [key, item[key]])), notes: String(item.notes || ""),
weekId: item.weekId, resourceId: String(resource.resource.resourcePartyId), tag: item.tag
}));
const url = endpoint.replace(/\/weekDetails$/, "");
const headers = await saveHeaders(url);
const result = await fetchJson(url, {
method: message.method, credentials: "include",
headers, body: JSON.stringify(items)
}, "requestTimeEntriesSave", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestActiveServiceRequests(message) {
const debugRequests = [];
try {
if (!/^\d+$/.test(String(message.weekId)) || Number(message.weekId) <= 0) throw new Error("Missing or invalid WeekId.");
const resource = await getResourceUser(message);
debugRequests.push(...resource.debugRequests);
const url = endpoint.replace(/weekDetails$/, "activeServiceRequests") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&weekId=${encodeURIComponent(message.weekId)}`;
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestActiveServiceRequests", debugRequests);
if (!Array.isArray(items)) throw new Error("Invalid active Service Requests response.");
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestServiceRequestExpand(message) {
const debugRequests = [];
try {
if (!Array.isArray(message.srNumbers)) throw new Error("Missing Service Request numbers.");
const numbers = [...new Set(message.srNumbers.map(value => String(value || "").trim()).filter(Boolean))].filter(value => value !== "non-service-sr");
if (numbers.some(value => !/^SR\d+$/.test(value))) throw new Error("Invalid Service Request number.");
const batches = [];
for (let index = 0; index < numbers.length; index += 50) batches.push(numbers.slice(index, index + 50));
if (!batches.length || batches[batches.length - 1].length === 50) batches.push([]);
batches[batches.length - 1].push("non-service-sr");
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/crmRestApiSearchResourcesLatestCustomActions/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const fields = "SrNumber,SRActivities_c,ProblemDescription,AccountPartyUniqueName,ServiceNew_c,OptyOwner_c,OptyName_c,OptyNumber_c,ReportedByPartyUniqueName,StatusCdMeaning,CountryText_c,AssigneePersonName,OptyStatus_c";
const items = [];
for (const batch of batches) {
const query = `SrNumber in (${batch.map(value => `'${value}'`).join(",")})`;
const url = `${base}?expand=ServiceRequest_SRToInternalSR_Tgt&fields=${encodeURIComponent(fields)}&limit=200&onlyData=true&q=${encodeURIComponent(query).replace(/'/g, "%27")}`;
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json", "vb-proxy-header-rest-framework-version": "3" } }, "requestServiceRequestExpand", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid Service Request expansion response.");
items.push(...result.items);
}
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestLookupTaskType() {
const debugRequests = [];
try {
const url = endpoint.replace(/mgmt\/timeEntries\/weekDetails$/, "crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM");
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestLookupTaskType", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid Task Type lookup response.");
return { ok: true, items: result.items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
return { requestWeekDetails, requestTimeEntries, requestTimeEntriesSave, requestTimeEntriesDelete, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
}

View File

@@ -1,6 +1,6 @@
function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT,
observeResponseHeaders = () => {},
environment
}) {
const { fetch } = environment;
@@ -17,6 +17,7 @@ function createHttpBackgroundJson({
throw error;
}
observeResponseHeaders(response.url || url, response.headers);
const responsePayload = await readResponsePayload(response);
const responseBody = createDebugBody(responsePayload.text);
completeBackgroundDebugEntry(debugEntry, response, responseBody, "");
@@ -125,7 +126,7 @@ function createHttpBackgroundJson({
}
function createDebugBody(text) {
return previewDebugBody(text, DEBUG_BODY_LIMIT);
return previewDebugBody(text);
}
function normalizeDebugHeaders(headers) {

View File

@@ -1,25 +1,86 @@
function createSpaCurrentUser({ fetchJson }) {
async function requestCurrentUser() {
function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) {
const pending = new Map();
function requestCurrentUser(sender) {
const key = JSON.stringify([sender?.tab?.windowId ?? null, sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
if (!pending.has(key)) {
pending.set(key, loadCurrentUser(sender).finally(() => pending.delete(key)));
}
return pending.get(key);
}
async function loadCurrentUser(sender) {
const debugRequests = [];
let user = null;
let sessionTab = null;
let receivedValidJson = false;
try {
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }
}, "requestCurrentUser", debugRequests);
if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) {
throw new Error("requestCurrentUser returned invalid user roles.");
// Bound each bootstrap cycle so a failed sign-in never leaves an orphan tab.
// The content-side gate can start another cycle without releasing dependent requests.
for (let attempt = 1; attempt <= 6; attempt++) {
try {
const result = sessionTab ? await fetchInTab(sessionTab, debugRequests) : await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }
}, "requestCurrentUser", debugRequests);
const validText = value => typeof value === "string" && value.trim().length > 0;
if (!result || typeof result !== "object" || Array.isArray(result)
|| !result.user || typeof result.user !== "object" || Array.isArray(result.user)
|| !validText(result.user.id) || !validText(result.user.name) || !validText(result.token)
|| !Array.isArray(result.user.roles) || result.user.roles.some(role => !validText(role))
|| ["onWhiteList", "provisioned", "administrator"].some(key => key in result.user && typeof result.user[key] !== "boolean")) {
const error = new Error("requestCurrentUser returned an invalid user/token response.");
error.code = "INVALID_CURRENT_USER_RESPONSE";
throw error;
}
const entry = debugRequests[debugRequests.length - 1];
if (entry) entry.metadata.attempt = String(attempt);
receivedValidJson = true;
const user = { id: result.user.id, name: result.user.name, roles: result.user.roles };
return { ok: true, user, debugRequests };
} catch (error) {
const entry = debugRequests[debugRequests.length - 1];
if (entry) {
entry.status = "failed";
entry.error = error.message;
entry.metadata.attempt = String(attempt);
}
const needsSession = error.code === "INVALID_JSON_RESPONSE" || error.code === "INVALID_CURRENT_USER_RESPONSE"
|| error.httpStatus === 401 || error.httpStatus === 403 || !error.httpStatus;
if (!needsSession || attempt === 6) {
if (entry && needsSession) entry.metadata.retryLimitReached = "true";
throw error;
}
if (entry) {
entry.metadata.sessionBootstrapUrl = sessionUrl;
entry.metadata.retryReason = "Current user requires a valid SPA session";
}
if (!sessionTab) {
try {
const tab = await openSessionTab(sender);
if (!Number.isInteger(tab?.id)) throw new Error("SPA session tab was not created.");
sessionTab = tab;
if (entry) entry.metadata.sessionBootstrapState = "opened-inactive-tab";
} catch (sessionError) {
if (entry) entry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`;
throw sessionError;
}
} else if (entry) {
entry.metadata.sessionBootstrapState = "waiting-for-valid-session";
}
await waitForSession(sessionTab, entry);
}
}
user = { id: result.user.id, name: result.user.name, roles: result.user.roles };
return { ok: true, user, debugRequests };
} catch (error) {
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
} finally {
// The identity token is not needed by content scripts or debug, including on failures.
for (const entry of debugRequests) {
entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2);
entry.responseBodyTruncated = false;
if (sessionTab) {
const entry = debugRequests[debugRequests.length - 1];
try {
await closeSessionTab(sessionTab.id);
if (entry) entry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json" : "closed-after-retry-finished";
} catch (error) {
if (entry) entry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`;
}
}
}
}

View File

@@ -0,0 +1,34 @@
function createSpaPageClient({ sendMessage, getHttp }) {
const options = { frameId: 0 };
const inspect = tab => sendMessage(tab.id, { type: "opportunitiesExtension.inspectSpaPage" }, options);
async function requestIdentity(tab, debugRequests) {
const http = getHttp();
const url = "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current";
const entry = http.createBackgroundDebugEntry("requestCurrentUser", url,
{ method: "GET", headers: { Accept: "application/json" } }, new Date());
entry.metadata.executionContext = "spa-tab";
try {
const result = await sendMessage(tab.id, { type: "opportunitiesExtension.readSpaIdentity", documentId: tab.spaDocumentId }, options);
if (!result || result.error || !Number.isInteger(result.status) || typeof result.body !== "string") {
throw new Error(result?.error || "Invalid SPA identity response.");
}
const response = { status: result.status, statusText: result.statusText || "", ok: result.status >= 200 && result.status < 300,
headers: { forEach: callback => Object.entries(result.headers || { "content-type": result.contentType || "" }).forEach(([key, value]) => callback(value, key)) } };
http.completeBackgroundDebugEntry(entry, response, http.createDebugBody(result.body), "");
entry.metadata.responseContentType = result.contentType || "(not provided)";
entry.metadata.responseLength = String(result.body.length);
if (result.status !== 200) {
const error = new Error(`requestCurrentUser failed with status ${result.status}.`);
error.httpStatus = result.status;
throw error;
}
return http.parseJsonResponse(result.body, "requestCurrentUser");
} catch (error) {
if (!entry.completedAt) http.completeBackgroundDebugEntry(entry, null, { value: "", truncated: false }, error.message);
entry.status = "failed";
entry.error = error.message;
throw error;
} finally { debugRequests.push(entry); }
}
return { inspect, requestIdentity };
}

View File

@@ -0,0 +1,79 @@
function createSpaWorkloads({ fetchJson, getCachedWorkloads, normalizeCacheMaxAge, invalidateWorkloadsCache }) {
async function requestWorkloadForecastUpdate(message) {
const debugRequests = [];
try {
const id = String(message.workloadId ?? "");
const token = message.authorization?.value;
if (!/^\d+$/.test(id)) throw new Error("A numeric workload ID is required.");
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
if (!Array.isArray(message.payload) || !message.payload.length) throw new Error("Monthly estimates are required.");
const periods = new Set();
const payload = message.payload.map(({ year, month, amount, updatedBy }) => {
if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12
|| typeof amount !== "number" || !Number.isFinite(amount) || amount < 0
|| typeof updatedBy !== "string" || !updatedBy.trim()) throw new Error("Invalid monthly estimate.");
const period = `${year}-${month}`;
if (periods.has(period)) throw new Error("Duplicate forecast month.");
periods.add(period);
return { year, month, amount, updatedBy: updatedBy.trim() };
});
const result = await fetchJson(`https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads/${id}/forecast`, {
method: "PATCH", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", "wwb-provider-authorization": token.trim() },
body: JSON.stringify(payload)
}, "requestWorkloadForecastUpdate", debugRequests, { allowEmpty: true });
await invalidateWorkloadsCache().catch(() => {});
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
async function requestWorkloads(message) {
const customerId = String(message.customerId ?? "").trim();
const token = message.authorization?.value;
if (!/^\d+$/.test(customerId)) throw new Error("A numeric CustomerAccount.PartyId is required.");
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const result = await getCachedWorkloads(customerId, token.trim(), normalizeCacheMaxAge(message.cacheMaxAgeMs), message.principal);
return { ok: true, customerId, ...result };
}
async function fetchAllWorkloads(customerId, token) {
const debugRequests = [];
const workloads = new Map();
let offset = 0;
let limit = 500;
try {
for (let page = 0; page < 1000; page += 1) {
const url = new URL(`https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/customers/${customerId}/workloads`);
for (const [name, value] of Object.entries({ workloadStatus: "Pipeline,Implementation,Live", opportunityForecastType: "All",
includeForEstimates: "ALL", financialYear: "ROLLING_4_QTRS", offset, limit })) url.searchParams.set(name, String(value));
const result = await fetchJson(url.toString(), {
method: "GET", credentials: "include",
headers: { Accept: "application/json", "wwb-provider-authorization": token }
}, "requestWorkloads", debugRequests);
if (!result || !Array.isArray(result.workloads) || typeof result.hasNext !== "boolean"
|| result.workloads.some(item => !item || !/^\d+$/.test(String(item.id ?? "")))) {
throw new Error("requestWorkloads returned an invalid collection.");
}
const countBefore = workloads.size;
for (const item of result.workloads) workloads.set(String(item.id), item);
if (!result.hasNext) return { workloads: [...workloads.values()], debugRequests };
const responseOffset = Number(result.offset ?? offset);
const responseLimit = Number(result.limit ?? limit);
if (workloads.size === countBefore || !Number.isSafeInteger(responseOffset) || responseOffset !== offset
|| !Number.isSafeInteger(responseLimit) || responseLimit <= 0 || !Number.isSafeInteger(responseOffset + responseLimit)) {
throw new Error("requestWorkloads pagination did not advance.");
}
offset = responseOffset + responseLimit;
limit = responseLimit;
}
throw new Error("requestWorkloads exceeded the pagination limit.");
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
return { requestWorkloads, fetchAllWorkloads, requestWorkloadForecastUpdate };
}

View File

@@ -1,40 +1,3 @@
function createDebugBodyPreview(text, limit) {
if (text.length <= limit) return { value: text || "(empty response body)", truncated: false };
try {
const normalized = text.replace(/^\uFEFF/, "").trimStart()
.replace(/^\)\]\}',?\s*/, "").replace(/^while\s*\(1\);\s*/, "");
let value = JSON.parse(normalized);
for (let depth = 0; depth < 2 && typeof value === "string" && /^\s*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
// Bound retained data without cutting JSON syntax or losing sensitive field names.
let remaining = Math.max(0, limit - 256);
const visit = (node, depth = 0) => {
if (remaining <= 64 || depth >= 30) return "[omitted from debug preview]";
if (node === null || typeof node !== "object") {
const serialized = JSON.stringify(node);
if (serialized.length > remaining) {
remaining = 0;
return "[omitted from debug preview]";
}
remaining -= serialized.length + 1;
return node;
}
const result = Array.isArray(node) ? [] : Object.create(null);
remaining -= 2;
for (const key of Object.keys(node)) {
const cost = Array.isArray(node) ? 1 : JSON.stringify(key).length + 2;
if (remaining < cost + 64) break;
remaining -= cost;
result[key] = visit(node[key], depth + 1);
}
return result;
};
return {
value: JSON.stringify({ debugNotice: "Partial response preview: remaining fields/items omitted due to the debug size limit.", preview: visit(value) }),
truncated: true
};
} catch {
return { value: `${text.slice(0, limit)}\n... truncated ${text.length - limit} characters`, truncated: true };
}
function createDebugBodyPreview(text) {
return { value: text || "(empty response body)", truncated: false };
}

View File

@@ -1,6 +1,14 @@
/** Stable wire names. All payloads remain plain, serializable objects. */
function createMessageTypes() {
return Object.freeze({
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
requestTimeEntries: "opportunitiesExtension.requestTimeEntries",
requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave",
requestTimeEntriesDelete: "opportunitiesExtension.requestTimeEntriesDelete",
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
requestResourceUser: "opportunitiesExtension.requestResourceUser",
requestActiveServiceRequests: "opportunitiesExtension.requestActiveServiceRequests",
getXsrfToken: "opportunitiesExtension.getXsrfToken",
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
@@ -11,10 +19,21 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestWorkloads: "opportunitiesExtension.requestWorkloads",
requestWorkloadForecastUpdate: "opportunitiesExtension.requestWorkloadForecastUpdate",
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
requestSrLookupPrimaryPillar: "opportunitiesExtension.requestSrLookupPrimaryPillar",
requestPostServiceRequest: "opportunitiesExtension.requestPostServiceRequest",
requestPostInternalServiceRequest: "opportunitiesExtension.requestPostInternalServiceRequest",
prepareSrSession: "opportunitiesExtension.prepareSrSession",
requestSrLookupLanguage: "opportunitiesExtension.requestSrLookupLanguage",
requestSrLookupTechPlatform: "opportunitiesExtension.requestSrLookupTechPlatform",
requestLookupTypePlatform: "opportunitiesExtension.requestLookupTypePlatform",
requestInternalSrServices: "opportunitiesExtension.requestInternalSrServices",
requestSrLookupOpportunityBusiness: "opportunitiesExtension.requestSrLookupOpportunityBusiness",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",

31
src/shared/time-dates.js Normal file
View File

@@ -0,0 +1,31 @@
function createTimeDates() {
const months = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(" ");
function parse(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value));
if (!match) return null;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value ? date : null;
}
function fromApi(value) {
const match = /^(\d{2})-([A-Z]{3})-(\d{4})$/.exec(String(value));
if (!match) return null;
const month = months.indexOf(match[2]) + 1;
return parse(`${match[3]}-${String(month).padStart(2, "0")}-${match[1]}`);
}
function toApi(value) {
const date = parse(value);
if (!date) throw new Error("Select a valid reference date.");
return `${String(date.getUTCDate()).padStart(2, "0")}-${months[date.getUTCMonth()]}-${date.getUTCFullYear()}`;
}
function shift(value, days) {
const date = parse(value);
if (!date) return value;
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function today() {
const date = new Date();
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
return { parse, fromApi, toApi, shift, today };
}

22
src/vendor/fullcalendar/LICENSE.md vendored Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2021 Adam Shaw
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

7
src/vendor/fullcalendar/NOTICE.md vendored Normal file
View File

@@ -0,0 +1,7 @@
FullCalendar Standard Bundle 6.1.19 (MIT)
Vendored from https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js
License: LICENSE.md in this directory.
The build embeds this pinned bundle in a lazy local factory. No CDN or runtime
code download is used by the extension. Only dayGridMonth and dayGridWeek are enabled.

File diff suppressed because one or more lines are too long

View File

@@ -44,6 +44,41 @@ function setup(family, saved = {}, principal = "user@example.invalid") {
}
return { app, calls, load, saved, local };
}
test("all cached opportunities load missing Actions despite filters, then reuse their completed cache", async () => {
const h = setup("firefox");
const stamp = Date.now() - 1000;
const app = h.app;
app.opportunitiesFilterState.tableSearch = "hide every opportunity";
app.productsContentJobs.renderProductsProgress = () => {};
const products = Array.from({ length: 7 }, (_, index) => [String(index + 1), {
items: [{ RevnId: String(index + 1) }], cachedAt: stamp, fromCache: true
}]);
app.productsContentState.opptyProductsByNumber = new Map(products);
await app.activityCache.write("actions", "1", stamp, []);
await app.activityCache.write("actions", "2", stamp - 1, []);
const queried = [];
app.platformContentMessages.sendRuntimeMessage = async message => {
queried.push(message.revnId);
return { ok: true, actions: message.revnId === "3" ? [{ owner: app.authSessionState.principal }] : [], hasNext: false };
};
async function cycle(id) {
app.productsContentState.currentProductsRequestId = id;
app.productActivities.begin(id, "synthetic-token");
await app.productActivities.complete(id);
}
await cycle("first");
assert.deepEqual(queried.sort(), ["2", "3", "4", "5", "6", "7"]);
assert.deepEqual(h.calls, ["sr"], "a missing SR cache must not block the Actions queue");
for (const [number] of products) {
assert.equal(app.opportunityWorkloadActions.getSummary(number).state, number === "3" ? "matched" : "missing");
}
app.opportunityWorkloadActions.reset();
app.opportunityCrmServiceRequests.reset();
await cycle("second");
assert.equal(queried.length, 6, "valid matching action cache avoids all repeat requests");
assert.deepEqual(h.calls, ["sr"]);
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: Current Quarter -> Next Quarter -> Current Quarter restores SR hours and filters without requests`, async () => {
const h = setup(family);

View File

@@ -154,7 +154,7 @@ for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SAL
assert.equal(controls.children[0].hidden, true, "hidden before identity resolves");
assert.equal(controls.children[1].hidden, true);
const identity = app.currentUser.ensure();
finish({ ok: true, user: { roles } });
finish({ ok: true, user: { id: "synthetic@example.invalid", name: "Synthetic User", roles } });
await identity;
app.activityFilters.render();
assert.equal(controls.children[0].hidden, !allowed);

View File

@@ -30,6 +30,13 @@ test("activity indicators share Products styles, fade after 5s, retain failures
assert.equal(timers.size, 0);
update("sr", "error");
assert.equal(nodes[0].textContent, "Service Requests unavailable");
update("session", "running");
assert.equal(nodes[2].textContent, "SR session loading");
assert.equal(nodes[2].attributes["aria-busy"], "true");
update("session", "complete");
assert.equal(nodes[2].textContent, "SR session ready");
assert.equal(nodes[2].attributes["aria-busy"], "false");
assert.equal([...timers.values()].at(-1).delay, 5000);
});
test("CRM reports real page counts and Actions reports queued and completed requests", async () => {

View File

@@ -4,7 +4,7 @@ const path = require("node:path");
const vm = require("node:vm");
const { createHash } = require("node:crypto");
const { test } = require("node:test");
const { sources, composeScript } = require("../scripts/compose.cjs");
const { sources, vendorSources, composeScript } = require("../scripts/compose.cjs");
const { loadApplication } = require("./support/application.cjs");
const root = path.resolve(__dirname, "..");
@@ -14,7 +14,13 @@ const json = value => JSON.parse(JSON.stringify(value));
test("every source belongs to an explicit runtime composition, with no duplicate factories", () => {
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
.filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort();
assert.deepEqual([...new Set(Object.values(sources).flat())].sort(), actual);
assert.deepEqual([...new Set([...Object.values(sources).flat(), ...Object.values(vendorSources).flat()])].sort(), actual);
for (const files of Object.values(vendorSources)) {
for (const file of files) {
assert.doesNotThrow(() => new vm.Script(read(file)));
assert.doesNotMatch(read(file), /\beval\s*\(|\bnew\s+Function\s*\(/, file);
}
}
for (const [target, files] of Object.entries(sources)) {
assert.equal(new Set(files).size, files.length);
const factories = files.flatMap(file => [...read(file).matchAll(/^function (\w+)\(/gm)].map(match => match[1]));
@@ -31,7 +37,7 @@ test("every source belongs to an explicit runtime composition, with no duplicate
}
}
}
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/messages.js", "src/shared/debug-body.js"]);
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/time-dates.js", "src/shared/messages.js", "src/shared/debug-body.js"]);
});
test("Redwood cascade is byte-identical to the characterized original", () => {
@@ -42,7 +48,7 @@ test("Redwood cascade is byte-identical to the characterized original", () => {
test("fiscal module can run alone without DOM, browser APIs or network", () => {
const factory = new vm.Script(`${read("src/features/opportunities/fiscal-periods.js")}\ncreateOpportunitiesFiscalPeriods;`).runInNewContext();
assert.deepEqual(json(factory().getFiscalPeriodRange("Current Fiscal Year", new Date(2027, 0, 15))), {
startCloseDate: "2026-06-01T00:00:00.000Z", endCloseDate: "2027-05-31T00:00:00.000Z"
startCloseDate: "2026-06-01T00:00:00.000Z", endCloseDate: "2027-06-01T00:00:00.000Z"
});
});

View File

@@ -56,7 +56,7 @@ test("consistent inline vectors replace raster icons without parentheses or lost
assert.equal(count.title, "3 products");
assert.equal(typeof count.listeners.click, "function");
const ramp = app.productsDetails.createProductsRampBadge(products);
checkIcon(ramp, "ramp", "1,2,3");
checkIcon(ramp, "ramp", "1 | 2 | 3");
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
checkIcon(missingRamp, "ramp", "N/D");
@@ -70,7 +70,7 @@ test("consistent inline vectors replace raster icons without parentheses or lost
checkIcon(sr, "sr", "4h");
assert.equal(sr.title, "Whether a Service Request has been created for the opportunities.");
assert.equal(typeof sr.listeners.click, "function");
checkIcon(app.opportunityCrmServiceRequests.createBadge("OTHER"), "sr", "0h");
checkIcon(app.opportunityCrmServiceRequests.createBadge("OTHER"), "sr", "0h+");
app.productsContentState.opptyProductsByNumber.set("TEST", products);
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });

View File

@@ -11,7 +11,7 @@ const FILTER_KEY = "opportunities-extension-filter-preferences";
const TYPE_KEY = "opportunities-extension-opportunity-type-preferences";
const CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
const DAY = 86400000;
const range = { startCloseDate: "2026-06-01T00:00:00.000Z", endCloseDate: "2026-08-31T00:00:00.000Z" };
const range = { startCloseDate: "2026-06-01T00:00:00.000Z", endCloseDate: "2026-09-01T00:00:00.000Z" };
const drain = () => new Promise(resolve => setImmediate(resolve));
const productRequest = (requestId, optyNumbers = ["SYNTH-1"]) => ({
type: PREFIX + "requestOpptyProducts", requestId, optyNumbers, accessToken: "synthetic-products-token"
@@ -40,11 +40,13 @@ test("distribution contracts: manifests, execution boundaries and deterministic
const base = {
manifest_version: 3, name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.2", permissions: ["cookies", "storage"],
version: "1.0.6", permissions: ["cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"],
content_scripts: [{
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
js: ["content.js"], all_frames: true, run_at: "document_idle"
}, {
matches: ["https://spa.oracle.com/*"], js: ["spa-page.js"], all_frames: false, run_at: "document_idle"
}]
};
for (const target of ["chromium", "firefox"]) {
@@ -57,27 +59,56 @@ test("distribution contracts: manifests, execution boundaries and deterministic
assert.equal(output, composeScript(file.replace(".js", "")));
assert.doesNotThrow(() => new vm.Script(output, { filename: `${target}/${file}` }));
}
const pageScript = fs.readFileSync(path.join(root, "dist", target, "spa-page.js"), "utf8");
assert.equal(pageScript, composeScript("spa-page"));
assert.doesNotThrow(() => new vm.Script(pageScript));
}
});
test("fiscal dates preserve quarter/year boundaries, leap year and unsupported renewals", () => {
const h = loadContent();
const cases = [
["Current Quarter", [2026, 5, 1], "2026-06-01", "2026-08-31"],
["Current Quarter", [2026, 7, 31], "2026-06-01", "2026-08-31"],
["Current Quarter", [2026, 8, 1], "2026-09-01", "2026-11-30"],
["Current Quarter", [2028, 1, 29], "2027-12-01", "2028-02-29"],
["Next Quarter", [2026, 10, 30], "2026-12-01", "2027-02-28"],
["Previous Quarter", [2026, 7, 24], "2026-03-01", "2026-05-31"],
["Current Fiscal Year", [2026, 4, 31], "2025-06-01", "2026-05-31"],
["Current Fiscal Year", [2026, 5, 1], "2026-06-01", "2027-05-31"],
["Current Fiscal Year", [2027, 0, 1], "2026-06-01", "2027-05-31"],
["4 Rolling Quarters (CQ + 3)", [2026, 8, 16], "2026-09-01", "2027-08-31"]
["Current Quarter", [2026, 0, 1], "2025-12-01", "2026-03-01"],
["Current Quarter", [2026, 1, 28], "2025-12-01", "2026-03-01"],
["Current Quarter", [2026, 2, 1], "2026-03-01", "2026-06-01"],
["Current Quarter", [2026, 4, 31], "2026-03-01", "2026-06-01"],
["Current Quarter", [2026, 5, 1], "2026-06-01", "2026-09-01"],
["Current Quarter", [2026, 7, 31], "2026-06-01", "2026-09-01"],
["Current Quarter", [2026, 8, 1], "2026-09-01", "2026-12-01"],
["Current Quarter", [2026, 9, 19], "2026-09-01", "2026-12-01"],
["Current Quarter", [2026, 10, 30], "2026-09-01", "2026-12-01"],
["Current Quarter", [2026, 11, 1], "2026-12-01", "2027-03-01"],
["Current Quarter", [2026, 11, 31], "2026-12-01", "2027-03-01"],
["Current Quarter", [2028, 1, 29], "2027-12-01", "2028-03-01"],
["Next Quarter", [2026, 0, 1], "2026-03-01", "2026-06-01"],
["Next Quarter", [2026, 4, 31], "2026-06-01", "2026-09-01"],
["Next Quarter", [2026, 5, 1], "2026-09-01", "2026-12-01"],
["Next Quarter", [2026, 10, 30], "2026-12-01", "2027-03-01"],
["Next Quarter", [2026, 11, 1], "2027-03-01", "2027-06-01"],
["Previous Quarter", [2026, 0, 1], "2025-09-01", "2025-12-01"],
["Previous Quarter", [2026, 2, 1], "2025-12-01", "2026-03-01"],
["Previous Quarter", [2026, 5, 1], "2026-03-01", "2026-06-01"],
["Previous Quarter", [2026, 8, 1], "2026-06-01", "2026-09-01"],
["Previous Quarter", [2026, 11, 1], "2026-09-01", "2026-12-01"],
["Current Fiscal Year", [2026, 0, 1], "2025-06-01", "2026-06-01"],
["Current Fiscal Year", [2026, 4, 31], "2025-06-01", "2026-06-01"],
["Current Fiscal Year", [2026, 5, 1], "2026-06-01", "2027-06-01"],
["Current Fiscal Year", [2027, 0, 1], "2026-06-01", "2027-06-01"],
["4 Rolling Quarters (CQ + 3)", [2026, 0, 1], "2025-12-01", "2026-12-01"],
["4 Rolling Quarters (CQ + 3)", [2026, 2, 1], "2026-03-01", "2027-03-01"],
["4 Rolling Quarters (CQ + 3)", [2026, 5, 1], "2026-06-01", "2027-06-01"],
["4 Rolling Quarters (CQ + 3)", [2026, 8, 16], "2026-09-01", "2027-09-01"],
["4 Rolling Quarters (CQ + 3)", [2026, 11, 1], "2026-12-01", "2027-12-01"]
];
for (const [period, date, start, end] of cases) {
assert.deepEqual(plain(h.getFiscalPeriodRange(period, new Date(...date))), {
startCloseDate: start + "T00:00:00.000Z", endCloseDate: end + "T00:00:00.000Z"
});
const dateRange = h.getFiscalPeriodRange(period, new Date(...date));
const criterion = h.createOpportunitiesQueryPayload(dateRange, 0).q.criteria[1];
assert.equal(criterion.value1, start + "T00:00:00.000+0000");
assert.equal(criterion.value2, end + "T00:00:00.000+0000");
assert.equal(dateRange.endCloseDate, end + "T00:00:00.000Z", "payload must not mutate the shared date range");
}
assert.equal(h.getFiscalPeriodRange("RENEWALS (Current + Past Due)", new Date()), null);
});
@@ -90,11 +121,11 @@ test("opportunity query preserves fields, record-set OR, date AND, and paginatio
assert.deepEqual(plain(h.createOpportunitiesQueryPayload(range, 15)), {
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "Opportunity",
q: { op: "$and", criteria: [
{ op: "$or", criteria: types.map(value => ({ op: "$eq", attribute: "RecordSet", value })) },
{ op: "$wi", attribute: "EffectiveDate", value1: range.startCloseDate, value2: range.endCloseDate, dynamicDate: false }
{ op: "$and", criteria: [{ op: "$or", criteria: types.map(value => ({ op: "$eq", attribute: "RecordSet", value })) }] },
{ op: "$wi", attribute: "EffectiveDate", value1: "2026-06-01T00:00:00.000+0000", value2: "2026-09-01T00:00:00.000+0000" }
] }, keywords: null, keywordsFields: fields,
fields: [...fields, "CustomerAccount.PartyUniqueName", "CustomerAccount.PartyId", "CustomerAccount.PartyNumber"],
sort: [], language: "en", skipInValidFields: true, skipHiddenFromUIFields: true,
fields: [...fields, "CustomerAccount.PartyUniqueName", "CustomerAccount.PartyId", "CustomerAccount.PartyNumber", "CustomerAccount.Region_c", "CustomerAccount.PrimaryAddress.CountryCode"],
sort: null, language: "en", skipInValidFields: true, skipHiddenFromUIFields: true,
copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", limit: 15, offset: 15
});
});
@@ -163,6 +194,15 @@ test("filter storage round-trip, WON_PENDING membership, search, sorting and ref
assert.equal(reopened.filters().ownerSearch, "");
});
test("removed renewals reference falls back to Current Quarter without losing other filters", () => {
const storage = new Map([[FILTER_KEY, JSON.stringify({ period: "RENEWALS (Current + Past Due)", tableSearch: "cloud" })]]);
const h = loadContent({ storage });
const select = { value: "Current Quarter" };
h.restoreFilterPreferences(select);
assert.equal(select.value, "Current Quarter");
assert.equal(h.filters().tableSearch, "cloud");
});
test("malformed preferences fall back, while theme/type/cache keys retain their schema", () => {
const storage = new Map([[FILTER_KEY, "not JSON"], ["opportunities-extension-theme", "dark"],
[TYPE_KEY, JSON.stringify({ opportunityTypeValues: ["ORA_MYOPTIES", "UNKNOWN"], opptyProductsCacheHours: 24 })]]);
@@ -183,6 +223,8 @@ test("tokenrelay missing-cookie recovery and HTTP failure do not change the requ
assert.equal(h.refreshes.length, 1);
assert.equal(calls.length, 1);
assert.equal(calls[0].options.headers["x-xsrf-token"], "synthetic-xsrf");
assert.equal(h.requestLog.at(-1).requestHeaders["x-xsrf-token"], "synthetic-xsrf");
assert.equal(JSON.parse(h.requestLog.at(-1).responseBody).access_token, "synthetic-access");
assert.equal(calls[0].options.method, "GET");
for (const status of [400, 401, 403]) {
let attempts = 0;

View File

@@ -26,7 +26,7 @@ for (const family of ["chrome", "firefox"]) {
assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].windowId, 3);
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
assert.equal(h.tabs[0].url, "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html");
assert.equal(h.tabs[0].url, "https://spa.oracle.com/");
assert.deepEqual(h.removedTabs, [91]);
assert.equal(result.debugRequests[0].httpStatus, status);
assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/);

View File

@@ -109,7 +109,7 @@ async function run() {
assert.equal(createdTabs[0].active, false);
assert.equal(
createdTabs[0].url,
"https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html"
"https://spa.oracle.com/"
);
assert.deepEqual(removedTabs, [27]);
assert.equal(response.debugRequests.length, 2);

View File

@@ -28,6 +28,14 @@ test("SR badge shows reported hours, sums linked SRs and preserves missing value
}
});
test("SR tooltip formats customer deadlines without timezone day shifts", async () => {
for (const [value, expected] of [["2026-05-14T00:00:00.000+0000", "14/05/2026"], [null, "N/D"], [undefined, "N/D"], ["", "N/D"]]) {
const app = setup(async () => response({ items: [{ ...sr("A"), CustomerDeadline_c: value }], hasMore: false }));
await app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.ok(app.opportunityCrmServiceRequests.createBadge("A").attributes["aria-label"].includes(`Customer Deadline: ${expected}`));
}
});
test("CRM fetch is called without the environment object as its receiver", async () => {
let calls = 0;
const app = setup(async function (url, options) {
@@ -76,7 +84,7 @@ test("CRM query preserves payload and pagination after opportunities, uses token
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
] }, keywords: null,
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "_actions", "Title", "Owner.PersonFirstName",
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "CustomerDeadline_c", "PrimaryPillarNew_c", "_actions", "Title", "Owner.PersonFirstName",
"Owner.PersonLastName", "Owner.PrimaryEmail.EmailAddress", "DoNotCallFlag", "DoNotEmailFlag"],
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"],
@@ -90,7 +98,11 @@ test("CRM query preserves payload and pagination after opportunities, uses token
assert.equal(log.status, "success");
assert.ok(log.responseBody);
assert.ok(log.durationMs >= 0);
assert.doesNotMatch(log.requestHeaders.Authorization, /synthetic-token/);
assert.equal(log.requestHeaders.Authorization, "Bearer synthetic-token");
assert.doesNotMatch(app.debugContentLog.formatHeaderBlock(log.requestHeaders), /synthetic-token/);
app.debugState.debugSecretsVisible = true;
assert.match(app.debugContentLog.formatHeaderBlock(log.requestHeaders), /Bearer synthetic-token/);
app.debugState.debugSecretsVisible = false;
assert.equal(app.debugContentView.getRequestCategory(log), "serviceRequests");
}
const found = app.opportunityCrmServiceRequests.createBadge("TEST-1");

View File

@@ -2,6 +2,292 @@ const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const type = "opportunitiesExtension.requestInternalServiceRequest";
test("internal SR form lookups use management proxy, Session and parent SrId", async () => {
const calls = [];
const h = loadBackground({ fetch: async (url, options) => {
calls.push({ url: new URL(url), options });
return response({ items: String(url).includes("/lookups?")
? [{ code: "OCI", name: "Oracle Cloud" }]
: [{ Id: 123, RecordName: "Review", Description_c: "Review the architecture" }] });
} });
const platform = await h.dispatch({ type: "opportunitiesExtension.requestLookupTypePlatform" });
assert.equal(platform.ok, true);
assert.equal(platform.items[0].code, "OCI");
const service = await h.dispatch({ type: "opportunitiesExtension.requestInternalSrServices", parentServiceId: "456", emailAddress: "user+test@example.invalid" });
assert.equal(service.ok, true);
assert.equal(service.items[0].Id, "123");
assert.equal(service.items[0].Description_c, "Review the architecture");
assert.equal(calls[1].url.searchParams.get("parentServiceId"), "456");
assert.equal(calls[1].url.searchParams.get("emailAddress"), "user+test@example.invalid");
assert.equal(calls[1].url.searchParams.get("serviceType"), "INTERNAL");
for (const call of calls) {
assert.match(call.url.pathname, /SeaaS_SR_management.*oalsetSeaaSOKECustomRestAPI/);
assert.equal(call.options.headers.Authorization, "Session");
assert.equal(call.options.credentials, "include");
}
assert.equal((await h.dispatch({ type: "opportunitiesExtension.requestInternalSrServices", parentServiceId: "bad", emailAddress: "user@example.invalid" })).ok, false);
assert.equal(calls.length, 2);
});
test("preparation reuses an existing unexpired cookie pair without navigation or API calls", async () => {
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const h = loadBackground({
fetch: async () => assert.fail("No API calls during preparation"),
cookies: () => [1, 2].map(part => ({ name: `ORA_OCIS_CG_SESSION_idcs-existing_${domain}_${part}`, value: `secret-${part}`, domain }))
});
const result = await h.dispatch({ type: "opportunitiesExtension.prepareSrSession" });
assert.equal(result.ok, true);
assert.equal(h.tabs.length, 0);
assert.equal(h.removedTabs.length, 0);
});
test("preparation closes immediately on the second cookie event even while getAll is pending", { timeout: 2000 }, async () => {
const fs = require("node:fs"), vm = require("node:vm"), path = require("node:path");
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/services/fusion/sr-details.js"), "utf8")}\ncreateFusionSrDetails`);
const listeners = new Set();
let reads = 0, closed = 0;
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookie = part => ({ name: `ORA_OCIS_CG_SESSION_idcs-new_${domain}_${part}`, value: `secret-${part}`, domain, storeId: "store" });
const emit = change => { for (const listener of listeners) listener(change); };
const api = factory({
fetchJson: async () => assert.fail("No API calls during preparation"),
cookieChanges: { addListener: fn => listeners.add(fn), removeListener: fn => listeners.delete(fn) },
cookiesGetAll: () => ++reads === 1 ? Promise.resolve([]) : new Promise(() => {}),
tabsCreate: async () => {
setImmediate(() => {
emit({ cookie: cookie(1) });
emit({ cookie: { ...cookie(2), storeId: "other" } });
assert.equal(closed, 0);
emit({ cookie: cookie(2), removed: true });
assert.equal(closed, 0);
emit({ cookie: cookie(2) });
});
return { id: 7 };
},
tabsRemove: async id => { assert.equal(id, 7); closed += 1; },
supportsFirefoxCookieStoreTabs: () => false,
wait: () => new Promise(() => {})
});
assert.equal((await api.prepareSession({ tab: { cookieStoreId: "store" } })).ok, true);
assert.equal(closed, 1);
assert.equal(listeners.size, 0);
});
test("explicit preparation opens SeaaS initiation and observes cookies without any API requests", async () => {
let calls = 0;
let reads = 0;
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const pair = [1, 2].map(part => ({ name: `ORA_OCIS_CG_SESSION_idcs-new_${domain}_${part}`, value: `secret-${part}`, domain }));
const h = loadBackground({ fetch: async () => {
calls += 1;
assert.fail("Opening the main modal must not trigger any session-dependent API request");
}, cookies: () => {
reads += 1;
if (reads === 1) return [];
assert.equal(h.tabs.length, 1);
if (reads === 2) return pair.slice(0, 1);
if (reads === 3) return pair.map(cookie => ({ ...cookie, expirationDate: 1 }));
return pair;
} });
const result = await h.dispatch({ type: "opportunitiesExtension.prepareSrSession" });
assert.equal(result.ok, true);
assert.equal(calls, 0);
assert.equal(reads, 4);
assert.deepEqual(result.debugRequests, []);
assert.ok(!JSON.stringify(result).includes("secret"));
assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/");
assert.deepEqual(h.removedTabs, [91]);
});
test("proactive preparation times out without issuing lookup probes", async () => {
let calls = 0;
const h = loadBackground({ messageTimeout: 10000, fetch: async () => { calls += 1; assert.fail("No probes allowed"); } });
const result = await h.dispatch({ type: "opportunitiesExtension.prepareSrSession" });
assert.equal(result.ok, false);
assert.equal(calls, 0);
assert.match(result.error, /session could not be established/);
assert.deepEqual(h.removedTabs, [91]);
});
test("cookie change wakes recovery without waiting for a polling timer and removes its listener", { timeout: 2000 }, async () => {
const fs = require("node:fs");
const vm = require("node:vm");
const path = require("node:path");
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/services/fusion/sr-details.js"), "utf8")}\ncreateFusionSrDetails`);
const listeners = new Set();
let ready = false;
let calls = 0;
let closed = 0;
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookies = [1, 2].map(part => ({ name: `ORA_OCIS_CG_SESSION_idcs-new_${domain}_${part}`, value: `secret-${part}`, domain, storeId: "store" }));
const api = factory({
cookieChanges: { addListener: fn => listeners.add(fn), removeListener: fn => listeners.delete(fn) },
cookiesGetAll: async () => ready ? cookies : [],
tabsCreate: async () => {
setImmediate(() => {
ready = true;
for (const listener of listeners) listener({ cookie: cookies[1] });
});
return { id: 7 };
},
tabsRemove: async () => { closed += 1; },
supportsFirefoxCookieStoreTabs: () => false,
wait: () => new Promise(() => {})
});
const result = await api.requestLookupWithSession(async () => {
calls += 1;
if (!ready) { const error = new Error("Unauthorized"); error.httpStatus = 401; throw error; }
return { ok: true, items: [] };
}, {}, { tab: { cookieStoreId: "store" } });
assert.equal(result.ok, true);
assert.equal(calls, 2);
assert.equal(closed, 1);
assert.equal(listeners.size, 0);
});
test("renewed matching cookie fragments trigger an early retry without leaking session values", async () => {
let reads = 0;
let calls = 0;
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookie = (id, part, value) => ({ name: `ORA_OCIS_CG_SESSION_idcs-${id}_${domain}_${part}`, value, domain, path: "/", session: true });
const h = loadBackground({
cookies: () => {
reads += 1;
if (reads < 3) return [cookie("old", 1, "old-secret-1"), cookie("old", 2, "old-secret-2")];
if (reads === 3) return [cookie("new", 1, "new-secret-1"), cookie("other", 2, "wrong-pair")];
return [cookie("new", 1, "new-secret-1"), cookie("new", 2, "new-secret-2")];
},
fetch: async (_, options) => {
calls += 1;
assert.equal(options.credentials, "include");
assert.equal(options.headers.Cookie, undefined);
if (calls === 1) return response({}, 401);
assert.equal(reads, 4, "retry immediately when both renewed parts match, not on a partial pair");
return response({ items: [] });
}
});
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupLanguage" });
assert.equal(result.ok, true);
assert.equal(calls, 2);
assert.ok(!JSON.stringify(result).includes("secret"));
assert.ok(h.cookieQueries.every(query => query.domain === domain && query.storeId === "test-store"));
assert.deepEqual(h.removedTabs, [91]);
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: simultaneous lookup 401s share an inactive session tab and close it after successful retries`, async () => {
const counts = new Map();
const h = loadBackground({ family, fetch: async url => {
const count = (counts.get(url) || 0) + 1;
counts.set(url, count);
return count < 3 ? response({}, 401) : response({ items: [] });
} });
const results = await Promise.all(["PrimaryPillar", "Language", "TechPlatform", "OpportunityBusiness"].map(name =>
h.dispatch({ type: `opportunitiesExtension.requestSrLookup${name}`, emailAddress: "test@example.invalid" })));
assert.ok(results.every(result => result.ok));
assert.ok(results.every(result => result.debugRequests.length === 3));
assert.equal(h.tabs.length, 1);
assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].windowId, 3);
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/");
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
assert.deepEqual(h.removedTabs, [91]);
});
}
test("lookup authentication timeout closes the temporary tab and returns a clear error", async () => {
const h = loadBackground({ messageTimeout: 10000, fetch: async () => response({}, 401) });
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupLanguage" });
assert.equal(result.ok, false);
assert.match(result.error, /session could not be established/);
assert.equal(h.tabs.length, 1);
assert.deepEqual(h.removedTabs, [91]);
});
test("SR language, platform and services use fixed Session endpoints and dynamic email", async () => {
const calls = [];
const h = loadBackground({ fetch: async (url, options) => {
calls.push({ url, options });
return response({ items: url.includes("/services?")
? [{ Id: 123, RecordName: "Consumption", Abstract_c: "Service summary" }]
: [{ code: "CODE", name: "Label", enabledFlag: "Y", displaySequence: 1 }] });
} });
for (const [name, lookup] of [["requestSrLookupLanguage", "SENGG_LANGUAGE"], ["requestSrLookupTechPlatform", "SENGG_TECH_PLATFORM"]]) {
const result = await h.dispatch({ type: `opportunitiesExtension.${name}` });
assert.deepEqual(result.items, [{ code: "CODE", name: "Label" }]);
assert.equal(new URL(calls.at(-1).url).searchParams.get("lookupType"), lookup);
assert.equal(result.debugRequests[0].name, name);
}
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupOpportunityBusiness", emailAddress: "user+test@example.invalid" });
assert.deepEqual(result.items, [{ Id: "123", RecordName: "Consumption", Abstract_c: "Service summary" }]);
const url = new URL(calls.at(-1).url);
assert.equal(url.searchParams.get("emailAddress"), "user+test@example.invalid");
assert.equal(url.searchParams.get("salesObject"), "OPPORTUNITY");
assert.equal(url.searchParams.get("serviceType"), "BUSINESS");
assert.ok(calls.every(call => call.options.headers.Authorization === "Session" && call.options.method === "GET"));
assert.equal((await h.dispatch({ type: "opportunitiesExtension.requestSrLookupOpportunityBusiness" })).ok, false);
assert.equal(calls.length, 3);
});
test("Primary pillar lookup uses Session, orders enabled name/code options and rejects invalid data", async () => {
const calls = [];
const h = loadBackground({ fetch: async (url, options) => {
calls.push({ url, options });
return response({ items: [
{ code: "B", name: "Second", displaySequence: 20, enabledFlag: "Y" },
{ code: "N", name: "Disabled", displaySequence: 1, enabledFlag: "N" },
{ code: "A", name: "First", displaySequence: 10, enabledFlag: "Y" }
] });
} });
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" });
assert.equal(result.ok, true);
assert.deepEqual(result.items, [{ code: "A", name: "First" }, { code: "B", name: "Second" }]);
assert.ok(calls[0].url.includes("SeaaS_initiation/live;profile=PROD"));
assert.ok(calls[0].url.endsWith("lookupType=SENGG_PRIMARY_PILLAR&type=STANDARD"));
assert.equal(calls[0].options.headers.Authorization, "Session");
assert.equal(calls[0].options.method, "GET");
for (const fetch of [async () => response({ items: [{}] }), async () => response({}, 401)]) {
assert.equal((await loadBackground({ fetch, messageTimeout: 10000 }).dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" })).ok, false);
}
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: 401 is returned to the caller without opening tabs`, async () => {
let count = 0;
const h = loadBackground({ family, fetch: async () => ++count < 3 ? response({}, 401) : response({ items: [] }) });
const result = await h.dispatch({ type, srId: "123" });
assert.equal(result.ok, false);
assert.equal(result.httpStatus, 401);
assert.equal(count, 1);
assert.equal(h.tabs.length, 0);
assert.deepEqual(h.removedTabs, []);
assert.deepEqual(result.debugRequests.map(entry => entry.httpStatus), [401]);
});
}
test("concurrent 401 responses do not cause background tab recovery", async () => {
let calls = 0;
const h = loadBackground({ fetch: async url => {
if (++calls <= 2) return response({}, 401);
const id = new URL(url).searchParams.get("q");
return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: [{ Title: id }] }] });
} });
const results = await Promise.all([h.dispatch({ type, srId: "1" }), h.dispatch({ type, srId: "2" })]);
assert.equal(h.tabs.length, 0);
assert.deepEqual(h.removedTabs, []);
assert.ok(results.every(result => result.httpStatus === 401));
});
test("background never creates recovery tabs after an error", async () => {
for (const status of [401, 500]) {
let count = 0;
const h = loadBackground({ fetch: async () => response({}, ++count === 1 ? 401 : status) });
const result = await h.dispatch({ type, srId: "1" });
assert.equal(result.ok, false);
assert.equal(count, 1);
assert.deepEqual(h.removedTabs, []);
}
const h = loadBackground({ fetch: async () => response({}, 403) });
assert.equal((await h.dispatch({ type, srId: "1" })).ok, false);
assert.equal(h.tabs.length, 0);
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
const calls = [];

View File

@@ -3,7 +3,88 @@ const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
test("internal SR uses Session without TokenRelay or retries on 401", async () => {
test("internal SR form validates Save, maps parent data, prevents duplicate posts and preserves failures", async () => {
const h = setup();
h.app.currentUser.getUserId = () => "current@example.invalid";
h.app.opportunitiesTableState.opportunitiesTableState.items = [
{ OptyNumber: "OTHER", Name: "Other opportunity", CustomerAccount: { PrimaryAddress: { CountryCode: "US" } } },
{ OptyNumber: "OP1", Name: "<Opportunity name>", OptyId: 456, CountryCode: "CA",
CustomerAccount: { PartyId: 789, Region_c_localizedValue: "LAD", CountryCode: "AR", PrimaryAddress: { CountryCode: "BR" } } }
];
h.app.debugContentView.renderDebugPanel = () => {};
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{ SrId: "123", SrNumber: "SR123",
CustomerDeadline_c: "2026-05-14T00:00:00.000+0000", PrimaryPillarNew_c: "Tech_Cloud_Infrastructure", Title: "<Parent SR title>", OpportunityName_Id_c: { OptyNumber: "OP1" } }];
const calls = [];
h.app.platformContentMessages.sendRuntimeMessage = async message => {
calls.push(message);
return { ok: true, items: message.type.endsWith("requestLookupTypePlatform") ? [{ code: "OCI", name: "Oracle Cloud" }]
: message.type.endsWith("requestInternalSrServices") ? [{ Id: "45", RecordName: "Review", Description_c: "Architecture review" }] : [] };
};
await h.app.opportunityCrmServiceRequests.load("synthetic", 1);
const badge = h.app.opportunityCrmServiceRequests.createBadge("OP1");
h.modal.append(badge);
badge.listeners.pointerenter();
await flush();
const add = h.walk(h.panel()).find(el => el.title === "Create Internal Service Request");
assert.ok(add);
assert.equal(add.parent.children[2].title, "Copy SR number");
add.listeners.click({ preventDefault() {}, stopPropagation() {} });
await flush();
const nodes = h.walk(h.modal);
const context = nodes.find(el => el.className === "opportunities-extension-action-form-context");
assert.deepEqual(context.children.map(el => el.textContent), ["SR Number", "SR123", "Opportunity", "<Opportunity name>", "Title", "<Parent SR title>"]);
assert.equal(nodes.find(el => el.name === "customerDeadline").value, "14/05/2026");
assert.equal(nodes.find(el => el.name === "customerDeadline").readOnly, true);
assert.equal(nodes.find(el => el.name === "customerDeadline").required, false);
for (const name of ["title", "internalDeadline", "platform", "description", "services"]) {
const input = nodes.find(el => el.name === name);
assert.equal(input.required, true, name);
assert.equal(input.attributes["aria-required"], "true", name);
assert.match(input.parent.children[0].textContent, /\(required\)$/);
}
for (const name of ["platform", "services"]) {
const input = nodes.find(el => el.id === `internal-sr-${name}-search`);
assert.equal(input.required, true);
assert.equal(input.attributes["aria-required"], "true");
}
assert.equal(nodes.find(el => el.name === "internalDeadline").type, "date");
assert.equal(nodes.find(el => el.name === "platform").children[1].value, "OCI");
assert.equal(nodes.find(el => el.name === "services").children[1].attributes["data-description"], "Architecture review");
assert.equal(calls.at(-1).parentServiceId, "123");
assert.equal(calls.at(-1).emailAddress, "current@example.invalid");
const form = nodes.find(el => el.tag === "form");
assert.deepEqual(h.walk(form).filter(el => el.tag === "button").map(el => el.textContent), ["Save", "Cancel"]);
const submit = () => form.listeners.submit({ preventDefault() {} });
const before = calls.length;
await submit();
assert.equal(calls.length, before);
assert.equal(nodes.find(el => el.name === "title").attributes["aria-invalid"], "true");
for (const [name, value] of Object.entries({ title: "New internal SR", internalDeadline: "2026-05-01", platform: "OCI", description: "Detailed request", services: "45" })) nodes.find(el => el.name === name).value = value;
const posts = [], toasts = [];
h.app.opportunitiesLinks.showCopyConfirmation = (message, isError = false) => toasts.push({ message, isError });
h.app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => posts.push({ message, resolve }));
const saving = submit();
await submit();
assert.equal(posts.length, 1);
assert.equal(posts[0].message.type, "opportunitiesExtension.requestPostInternalServiceRequest");
assert.equal(posts[0].message.emailAddress, "current@example.invalid");
assert.deepEqual(JSON.parse(JSON.stringify(posts[0].message.payload)), {
Title: "New internal SR", ServiceNew_Id_c: "45",
ProblemDescription: "Detailed request", CustomerDeadline_c: "2026-05-14", TechPlatformLookUp_c: "OCI",
ServiceRequest_Id_SRToInternalSR: "123", SvcQuestionsCollection_c: [], InternalDeadline_c: "2026-05-01"
});
posts[0].resolve({ ok: false, httpStatus: 403, error: "Synthetic save failure" });
await saving;
assert.equal(nodes.find(el => el.name === "title").value, "New internal SR");
assert.deepEqual(toasts, [{ message: "Synthetic save failure", isError: true }]);
const retry = submit();
posts[1].resolve({ ok: true, result: { SrNumber: "ISR1" } });
await retry;
assert.deepEqual(toasts.at(-1), { message: "Internal Service Request created successfully.", isError: false });
assert.ok(!h.walk(h.modal).includes(form));
});
test("internal SR uses Session without TokenRelay and reports unavailable iframe host", async () => {
const h = setup();
let renewals = 0;
const sent = [];
@@ -24,7 +105,7 @@ test("internal SR uses Session without TokenRelay or retries on 401", async () =
assert.equal(sent.length, 1);
assert.equal(sent[0].token, undefined);
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
assert.ok(h.walk(h.panel()).some(node => node.textContent === "Unauthorized"));
assert.ok(h.walk(h.panel()).some(node => node.attributes.role === "alert"));
});
function setup() {
@@ -146,7 +227,8 @@ test("SR tooltip loads nested details with copy/link and empty text, discarding
assert.equal(calls[0].message.token, undefined);
assert.equal(calls[0].message.srId, "NOT-THE-URL-ID");
assert.ok(h.walk(h.panel()).some(node => node.textContent === "Loading SR details..."));
calls[0].resolve({ ok: true, items: [{ SrId: 99, SrNumber: "SR &child", Title: "<child>", ServiceNew_c: "Queue" }] });
calls[0].resolve({ ok: true, items: [{ SrId: 99, SrNumber: "SR &child", Title: "<child>", ServiceNew_c: "Queue",
LastUpdateDate: "2026-05-14T00:00:00.000+0000", OpenDate: "2026-04-01T23:30:00.000+0000" }] });
calls[1].resolve({ ok: true, items: [] });
await flush();
const nodes = h.walk(h.panel());
@@ -155,6 +237,8 @@ test("SR tooltip loads nested details with copy/link and empty text, discarding
assert.ok(link.href.endsWith("srNumber=SR%20%26child&srId=99"));
assert.equal(link.parent.children[2].title, "Copy SR number");
assert.ok(nodes.some(node => node.textContent.includes("Service Queue: Queue")));
assert.ok(nodes.some(node => node.textContent.includes("LastUpdateDate: 14/05/2026")));
assert.ok(nodes.some(node => node.textContent.includes("OpenDate: 01/04/2026")));
assert.ok(nodes.some(node => node.textContent === "No further details."));
h.app.crmSrPopover?.close();
h.app.opportunityCrmServiceRequests.reset();

View File

@@ -3,13 +3,18 @@ const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const { loadApplication } = require("./support/application.cjs");
const type = "opportunitiesExtension.requestCurrentUser";
const validIdentity = {
user: { id: "synthetic@example.invalid", name: "Synthetic User", onWhiteList: false,
provisioned: true, administrator: false, roles: ["SALES_REP", "SIMPLE_USER"] },
token: "synthetic-secret"
};
for (const family of ["chrome", "firefox"]) {
test(`${family}: current user GET uses SPA cookies and discards identity token before messaging/debug`, async () => {
test(`${family}: current user GET preserves identity token only in debug data for explicit reveal`, async () => {
const calls = [];
const app = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
return response({ user: { id: "synthetic@example.invalid", roles: ["SALES_REP", "SIMPLE_USER"] }, token: "synthetic-secret" });
return response(validIdentity);
} });
const result = await app.dispatch({ type });
assert.equal(result.ok, true);
@@ -20,12 +25,18 @@ for (const family of ["chrome", "firefox"]) {
assert.equal(calls[0].options.cache, "no-store");
assert.equal(result.debugRequests[0].name, "requestCurrentUser");
assert.equal(result.debugRequests[0].httpStatus, 200);
assert.doesNotMatch(JSON.stringify(result), /synthetic-secret/);
assert.equal(JSON.parse(result.debugRequests[0].responseBody).token, "synthetic-secret");
const content = loadApplication("content");
assert.doesNotMatch(content.debugContentLog.formatDebugBody(result.debugRequests[0].responseBody, "requestCurrentUser"), /synthetic-secret/);
content.debugState.debugSecretsVisible = true;
assert.match(content.debugContentLog.formatDebugBody(result.debugRequests[0].responseBody, "requestCurrentUser"), /synthetic-secret/);
assert.equal(result.token, undefined);
assert.equal(app.tabs.length, 0, "a valid existing session must not open a tab");
assert.equal(app.removedTabs.length, 0);
});
}
test("identity failures and malformed roles remain explicit and never expose response tokens", async () => {
test("identity failures retain diagnostics but hide response tokens by default", async () => {
for (const fetch of [
async () => new Response("synthetic-secret<html>fallback</html>"),
async () => response({ token: "synthetic-secret", user: {} }),
@@ -37,12 +48,121 @@ test("identity failures and malformed roles remain explicit and never expose res
const result = await app.dispatch({ type });
assert.equal(result.ok, false);
assert.equal(result.debugRequests[0].status, "failed");
assert.doesNotMatch(JSON.stringify(result), /synthetic-secret/);
const content = loadApplication("content");
assert.doesNotMatch(content.debugContentLog.formatDebugBody(result.debugRequests[0].responseBody, "requestCurrentUser"), /synthetic-secret/);
}
});
function content(identity) {
const app = loadApplication("content", { document: { createElement: () => ({ setAttribute() {} }) } });
test("retry timer is called without the environment object as its receiver", async () => {
let attempts = 0;
let timers = 0;
const { app } = content(() => ++attempts === 1 ? { ok: false } : { ok: true, user: validIdentity.user }, {
setTimeout: function(callback) {
"use strict";
assert.equal(this, undefined, "Window timers reject an arbitrary receiver in Edge");
timers++;
setImmediate(callback);
}
});
await app.currentUser.ensure();
assert.equal(attempts, 2);
assert.equal(timers, 1);
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: current-user SSO recovery shares one verified SPA tab and closes on valid JSON`, async () => {
let attempts = 0;
const app = loadBackground({ family, fetch: async (url, options) => {
assert.equal(url, "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current");
assert.equal(options.credentials, "include");
attempts++;
if (attempts <= 2) return new Response('<html><body onload="document.forms[0].submit()"><form method="POST"></form></body></html>');
assert.equal(app.tabs.length, 1, "SSO must be started before retry can succeed");
assert.equal(app.removedTabs.length, 0, "do not close before a valid identity response");
return response(validIdentity);
} });
const [first, second] = await Promise.all([app.dispatch({ type }), app.dispatch({ type })]);
assert.equal(first.ok, true);
assert.deepEqual(first, second);
assert.equal(attempts, 3);
assert.deepEqual(app.tabs, [{
url: "https://spa.oracle.com/",
active: false, windowId: 3, ...(family === "firefox" ? { cookieStoreId: "test-store" } : {})
}]);
assert.deepEqual(app.removedTabs, [91]);
assert.equal(first.debugRequests[0].metadata.sessionBootstrapState, "opened-inactive-tab");
assert.equal(first.debugRequests[1].metadata.sessionBootstrapState, "waiting-for-valid-session");
assert.equal(first.debugRequests[2].metadata.sessionBootstrapState, "closed-after-valid-json");
});
}
test("expired SPA session recovery handles 401, 403 and network errors, then releases its in-flight request", async () => {
let attempts = 0;
const app = loadBackground({ fetch: async () => {
attempts++;
if (attempts === 1) return response({}, 401);
if (attempts === 2) return response({}, 403);
if (attempts === 3) throw new TypeError("Failed to fetch");
return response(validIdentity);
} });
const first = await app.dispatch({ type });
assert.equal(first.ok, true);
assert.equal(app.tabs.length, 1);
assert.deepEqual(first.debugRequests.map(entry => entry.httpStatus), [401, 403, null, 200]);
assert.deepEqual(app.removedTabs, [91]);
assert.equal((await app.dispatch({ type })).ok, true);
assert.equal(attempts, 5, "background must not persist identity after the shared request settles");
assert.equal(app.tabs.length, 1);
});
test("failed recovery closes its own tab and allows another cycle; server errors do not open SPA", async () => {
let ready = false;
const app = loadBackground({ fetch: async () => ready ? response(validIdentity) : new Response("<html>Sign in</html>") });
const failure = await app.dispatch({ type });
assert.equal(failure.ok, false);
assert.equal(failure.debugRequests.length, 6);
assert.deepEqual(app.removedTabs, [91]);
assert.equal(failure.debugRequests.at(-1).metadata.retryLimitReached, "true");
ready = true;
assert.equal((await app.dispatch({ type })).ok, true);
const unavailable = loadBackground({ fetch: async () => response({}, 503) });
assert.equal((await unavailable.dispatch({ type })).ok, false);
assert.equal(unavailable.tabs.length, 0);
});
test("SPA tab opening failures stop API probes; a later cycle and cleanup failure preserve valid identity", async () => {
let listener;
let attempts = 0;
let opens = 0;
const app = loadApplication("background", {
chrome: { runtime: { onMessage: { addListener: callback => { listener = callback; } } } }, URL,
fetch: async () => { attempts++; return new Response("<html>SSO</html>"); }
});
app.platformBackgroundApi.tabsCreate = async details => {
assert.equal(details.active, false);
if (++opens === 1) throw new Error("Temporary tab error");
return { id: 92 };
};
app.platformBackgroundApi.wait = async () => {};
app.platformBackgroundApi.tabsSendMessage = async (id, message) => message.type === "opportunitiesExtension.inspectSpaPage"
? { status: "ready", documentId: "test-document" }
: { status: 200, contentType: "application/json", body: JSON.stringify(validIdentity) };
app.platformBackgroundApi.tabsRemove = async id => { assert.equal(id, 92); throw new Error("Tab already closed"); };
app.start();
const dispatch = () => new Promise(resolve => listener({ type }, { tab: { windowId: 3 } }, resolve));
const failure = await dispatch();
assert.equal(failure.ok, false);
assert.equal(attempts, 1);
assert.match(failure.debugRequests[0].metadata.sessionBootstrapState, /tab-open-failed/);
const result = await dispatch();
assert.equal(result.ok, true);
assert.equal(opens, 2);
assert.equal(attempts, 2);
assert.match(result.debugRequests[1].metadata.sessionBootstrapState, /tab-close-failed/);
});
function content(identity, overrides = {}) {
const app = loadApplication("content", { document: { createElement: () => ({ setAttribute() {} }) }, ...overrides });
app.debugContentView.renderDebugPanel = () => {};
const calls = [];
app.platformContentMessages.sendRuntimeMessage = async message => {
@@ -71,7 +191,7 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
assert.equal(app.currentUser.allowsCrmActivities(), false);
const same = app.currentUser.ensure();
assert.equal(same, app.currentUser.ensure());
finish({ ok: true, user: { roles } });
finish({ ok: true, user: { ...validIdentity.user, roles } });
await first;
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]);
app.productsContentState.currentProductsRequestId = "products-cycle";
@@ -93,15 +213,63 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
});
}
test("identity failure disables only CRM/Actions and does not retry on Reference changes", async () => {
test("identity failures retry with shared backoff instead of being cached as a final result", async () => {
for (const identity of [async () => ({ ok: false }), async () => ({ ok: true, user: {} }), async () => { throw new Error("Offline"); }]) {
const { app, calls } = content(identity);
await app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter");
await app.opportunitiesController.requestOpportunitiesForPeriod("Next Quarter");
assert.equal(calls.filter(value => value === type).length, 1);
assert.equal(calls.filter(value => value === "opportunities").length, 2);
assert.equal(calls.includes("crm"), false);
app.opportunityWorkloadActions.loadProducts({ items: [{ RevnId: "123" }] });
assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), false);
const timers = [];
const delays = [];
let attempts = 0;
const { app, calls } = content(() => ++attempts <= 6 ? identity() : { ok: true, user: validIdentity.user }, {
setTimeout: (callback, delay) => { timers.push(callback); delays.push(delay); }
});
const first = app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter");
const second = app.opportunitiesController.requestOpportunitiesForPeriod("Next Quarter");
assert.equal(app.currentUser.ensure(), app.currentUser.ensure());
for (let i = 0; i < 6; i++) {
await new Promise(resolve => setImmediate(resolve));
assert.ok(calls.every(value => value === type));
assert.equal(timers.length, 1);
assert.equal(app.currentUser.getUserId(), "");
timers.shift()();
}
await Promise.all([first, second]);
assert.deepEqual(delays, [1000, 2000, 4000, 8000, 10000, 10000]);
assert.equal(attempts, 7);
assert.deepEqual(calls.slice(-2), ["tokenrelay", "opportunities"]);
await app.currentUser.ensure();
assert.equal(attempts, 7);
assert.equal(app.currentUser.getUserId(), validIdentity.user.id);
}
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: HTML, malformed JSON and invalid identity objects retry until valid user/token JSON`, async () => {
const payloads = [
() => new Response("<html>Sign in</html>"),
() => new Response('{"user":'),
() => response(null),
() => response([]),
() => response({}),
() => response({ ...validIdentity, token: "" }),
() => response({ ...validIdentity, user: { ...validIdentity.user, id: "" } }),
() => response({ ...validIdentity, user: { ...validIdentity.user, name: null } }),
() => response({ ...validIdentity, user: { ...validIdentity.user, roles: [null] } }),
() => response({ ...validIdentity, user: { ...validIdentity.user, provisioned: "true" } }),
() => response(validIdentity)
];
let attempts = 0;
const background = loadBackground({ family, fetch: async () => payloads[attempts++]() });
const { app } = content(() => background.dispatch({ type }), { setTimeout: callback => setImmediate(callback) });
const logs = [];
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(...entries);
const pending = app.currentUser.ensure();
assert.equal(pending, app.currentUser.ensure());
await pending;
assert.equal(attempts, payloads.length);
assert.equal(app.currentUser.getUserId(), validIdentity.user.id);
assert.equal(app.currentUser.allowsCrmActivities(), false, "valid SALES_REP identity must not retry");
assert.equal(logs.filter(entry => entry.status === "failed").length, payloads.length - 1);
assert.equal(logs.at(-1).status, "success");
await app.currentUser.ensure();
assert.equal(attempts, payloads.length);
});
}

View File

@@ -20,6 +20,26 @@ function fixture() {
return { ...dom, app, add, nodes, tab, ids, select };
}
test("catalog and identity secrets are revealed exactly by the eye button and hidden again", () => {
const h = fixture();
const body = JSON.stringify({ result: "catalog-exact-secret", detail: "Preserved" });
h.app.debugContentLog.appendBackgroundRequestLog([{
name: "requestSrTokenCatalog", method: "POST", startedAt: new Date().toISOString(), status: "success",
responseBody: body, requestHeaders: { "vb-proxy-header-x-oracle-ectoken": "header-exact-secret", "x-appbuilder-client-id": "client-exact-secret" }
}, {
name: "requestCurrentUser", method: "GET", startedAt: new Date().toISOString(), status: "success",
responseBody: JSON.stringify({ user: { roles: ["SIMPLE_USER"] }, token: "identity-exact-secret" })
}]);
const text = node => node.text + node.children.map(text).join("");
const toggle = () => h.nodes().find(node => node.getAttribute("data-debug-focus") === "visibility").listeners.click();
assert.doesNotMatch(text(h.panel), /exact-secret/);
toggle();
for (const secret of ["catalog-exact-secret", "header-exact-secret", "client-exact-secret", "identity-exact-secret"]) assert.ok(text(h.panel).includes(secret));
toggle();
assert.doesNotMatch(text(h.panel), /exact-secret/);
assert.equal(h.app.debugState.requestLog[0].responseBody, body, "display masking never overwrites originals");
});
test("request summaries show duration after status, including zero, failure and pending", () => {
const h = fixture();
h.add("duration", "requestOpportunities", 1);
@@ -33,23 +53,23 @@ test("request summaries show duration after status, including zero, failure and
}
});
test("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
test("complete large responses stay formatted, masked and colored in both runtimes", () => {
const h = fixture();
const background = loadApplication("background");
const body = JSON.stringify({ items: Array.from({ length: 2000 }, (_, index) => ({
id: index, name: "Synthetic opportunity", access_token: "synthetic-secret",
uiaasHeader: { value: "synthetic-header-secret" }, owner: { name: "Synthetic owner" }
})) });
for (const [logger, limit] of [[h.app.debugContentLog, 12000], [background.httpBackgroundJson, 100000]]) {
for (const logger of [h.app.debugContentLog, background.httpBackgroundJson]) {
const preview = logger.createDebugBody(body);
assert.equal(preview.truncated, true);
assert.ok(preview.value.length <= limit);
assert.equal(preview.truncated, false);
assert.equal(preview.value, body);
const parsed = JSON.parse(preview.value);
assert.match(parsed.debugNotice, /Partial response/);
assert.ok(parsed.preview.items.length > 0);
assert.ok(parsed.preview.items.length < 2000);
assert.equal(parsed.items.length, 2000);
assert.equal(parsed.items.at(-1).id, 1999);
const formatted = h.app.debugContentLog.formatDebugBody(preview.value);
assert.match(formatted, /\n "debugNotice"/);
assert.match(formatted, /\n "items"/);
assert.match(formatted, /"id": 1999/);
assert.doesNotMatch(formatted, /synthetic-secret|synthetic-header-secret/);
const container = h.document.createElement("div");
h.app.debugContentView.appendDebugBlock(container, "Response Body", formatted);
@@ -58,7 +78,9 @@ test("large response previews stay valid, formatted, masked and colored in both
assert.match(h.app.debugContentLog.formatDebugBody(preview.value), /synthetic-secret/);
h.app.debugState.debugSecretsVisible = false;
assert.equal(logger.createDebugBody('{"ok":true}').value, '{"ok":true}');
assert.match(logger.createDebugBody("<html>" + "x".repeat(limit + 1000)).value, /truncated/);
const html = "<html>" + "x".repeat(150000) + "</html>";
assert.equal(logger.createDebugBody(html).value, html);
assert.equal(logger.createDebugBody("").value, "(empty response body)");
}
});
@@ -69,7 +91,7 @@ test("request categories cover every current family and unknown requests stay vi
requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication",
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests", requestSRInfo: "serviceRequests",
requestConsumptionPlanDetail: "consumption",
requestRatecardByPlan: "consumption",
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
@@ -269,7 +291,8 @@ test("full-page debug CSS defines responsive bounds and readable semantic colors
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
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", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.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", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css")
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));

View File

@@ -1,18 +1,22 @@
(() => {
const listeners = [];
const storage = new Map();
const internalSrPreview = new URLSearchParams(location.search).has("internal-sr");
const projectionPreview = new URLSearchParams(location.search).has("projection");
const opportunities = Array.from({ length: 25 }, (_, index) => ({
OptyNumber: `TEST${index + 1}`,
OptyId: 1000 + index,
Name: `Synthetic opportunity ${index + 1} - Database migration`,
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: "test-party", PartyNumber: "test-account" },
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: internalSrPreview || projectionPreview ? 789 : "test-party", PartyNumber: "test-account", Region_c_localizedValue: "LAD", PrimaryAddress: { CountryCode: "BR" } },
Owner: { PersonFirstName: "Example", PersonLastName: "Owner" },
PrimaryRevenue: { RevnAmountCurcyCode: "USD", RevnAmount: "25000", WinProb: 60 },
EffectiveDate: "2026-11-15T00:00:00Z",
EffectiveDate: index === 24 ? null : `2026-11-${String(index < 8 ? 15 : 1 + index).padStart(2, "0")}T00:00:00Z`,
LastUpdateDate: "2026-09-15T17:00:00Z",
ForecastGroup_c: ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"][index % 5],
StatusCode: ["OPEN", "CLOSED", "LOST", "WON", "WON_PENDING"][index % 5]
}));
const products = [{ ProdGroupName: "Migrate databases to cloud", WorkloadName_c: "Example workload", RevnAmountCurcyCode: "USD", RevnAmount: "25000", TypeCode: "NEWCOMMIT", StatusCode: "OPEN", WinProb: 60, EffectiveDate: "2026-11-15", ConsumptionStartDate_c: "2026-11-15", RampMonths_c: 3 }];
if (projectionPreview) products[0] = { ...products[0], RevnId: 123, RampMonths_c: 2 };
const trend = Array.from({ length: 30 }, (_, index) => ({
fyDate: `2026-09-${String((index % 28) + 1).padStart(2, "0")}`,
fiscalTime: [`${String((index % 28) + 1).padStart(2, "0")}-Sep-26`],
@@ -24,10 +28,40 @@
accountSummary: { label: "Account-Ti-Authorization", value: "synthetic-accp" },
accountPlans: { label: "G2m-Authorization", value: "synthetic-ocicons" }
};
if (projectionPreview) authorizations.workloadActions = { label: "Wwb-Provider-Authorization", value: "synthetic-wwb" };
const runtime = {
onMessage: { addListener: listener => listeners.push(listener) },
async sendMessage(message) {
const operation = message.type.split(".").at(-1);
if (operation === "requestWorkloadForecastUpdate") return { ok: true, result: null, debugRequests: [] };
if (operation === "requestWorkloads") return { ok: true, workloads: [{ id: 123, name: "Example workload",
adjustedACR: 50000, consumptionStartDate: "2026-11-15T00:00:00", forecast: Array.from({ length: 12 }, (_, index) => ({
month: (5 + index) % 12 + 1, year: index < 7 ? 2026 : 2027,
adjustedConsumptionAmount: index < 5 ? 0 : index === 5 ? 1000 : index === 6 ? 3030.30 : 4500
})) }], debugRequests: [] };
if (operation === "requestTimeEntries") return { ok: true, items: [], debugRequests: [] };
if (operation === "requestLookupTaskType") return { ok: true, items: [{ lookUpCode_c: "SCTA_SERVICE_CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_SERVICE_STANDARD", lookUpMeaning_c: "Standard", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_NON_SERVICE_TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }], debugRequests: [] };
if (operation === "requestServiceRequestExpand") return { ok: true, items: [...message.srNumbers, "non-service-sr"].map(SrNumber => ({ SrNumber, SRActivities_c: "Account Plan;Customer Research, Assessment and Recommendation;Success Plan" })), debugRequests: [] };
if (operation === "requestResourceUser") return { ok: true, resource: { resourcePartyId: 123 }, debugRequests: [] };
if (operation === "requestActiveServiceRequests") return { ok: true, items: [
{ srNumber: "SR0001", srTitle: "Opportunity Pursuit - Example Customer" },
{ srNumber: "SR0002", srTitle: "Consumption Services - Example Customer" }
], debugRequests: [] };
if (operation === "requestWeekDetails") {
const selected = new Date(`${message.dateReference}T00:00:00Z`);
const start = new Date(selected); start.setUTCDate(start.getUTCDate() - (start.getUTCDay() + 1) % 7);
const end = new Date(start); end.setUTCDate(end.getUTCDate() + 6);
const apiDate = value => `${String(value.getUTCDate()).padStart(2, "0")}-${value.toLocaleDateString("en-US", { month: "short", timeZone: "UTC" }).toUpperCase()}-${value.getUTCFullYear()}`;
return { ok: true, items: Array.from({ length: 7 }, (_, index) => {
const day = new Date(start); day.setUTCDate(day.getUTCDate() + index);
return { Id: index + 1, WeekId: 419, Date: apiDate(day), StartDate: apiDate(start), EndDate: apiDate(end) };
}), debugRequests: [] };
}
if (operation === "requestPostInternalServiceRequest") return { ok: true, result: { SrNumber: "ISR123" }, debugRequests: [] };
if (operation === "requestCurrentUser") return { ok: true, user: { id: "example@example.invalid", name: "Example Owner", roles: internalSrPreview ? [] : ["SALES_REP"] }, debugRequests: [] };
if (["prepareSrSession", "requestInternalServiceRequest", "requestWlActions"].includes(operation)) return { ok: true, items: [], actions: [], debugRequests: [] };
if (operation === "requestLookupTypePlatform") return { ok: true, items: [{ code: "OCI", name: "Oracle Cloud Infrastructure" }, { code: "MULTI", name: "Multicloud" }], debugRequests: [] };
if (operation === "requestInternalSrServices") return { ok: true, items: [{ Id: "123", RecordName: "Architecture review", Description_c: "Review the customer architecture, workloads and technical requirements." }, { Id: "124", RecordName: "Technical workshop", Description_c: "Prepare and deliver a technical session for the customer team." }], debugRequests: [] };
if (operation === "getXsrfToken") return { ok: true, token: "synthetic-xsrf", cookieName: "XSRF-TOKEN-TEST" };
if (operation === "requestConsumerTokenService") return { ok: true, authorizations, debugRequests: [] };
if (operation === "requestOpptyProducts") {
@@ -64,7 +98,8 @@
let body;
if (url.pathname === "/fscmRestApi/tokenrelay") body = { access_token: "synthetic-fusion-token", expires_in: 3600 };
else if (url.pathname.endsWith("custom-actions/queries")) {
const { offset, limit } = JSON.parse(options.body);
const { offset, limit, entity } = JSON.parse(options.body);
if (entity === "CRMServiceRequest") return new Response(JSON.stringify({ items: [{ SrId: "123456", SrNumber: "SR123456", Title: "Example parent SR", PrimaryPillarNew_c: "Tech_Cloud_Infrastructure", CustomerDeadline_c: "2026-11-30T00:00:00.000+0000", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "TEST1" } }], hasMore: false }), { status: 200 });
body = { items: opportunities.slice(offset, offset + limit), totalResults: opportunities.length, count: Math.min(limit, opportunities.length - offset), hasMore: offset + limit < opportunities.length, offset, limit };
} else if (url.pathname.endsWith("summarizations/generate")) body = { summaryText: "Synthetic AI summary.\nText is displayed safely: <script>never executed</script>." };
else throw new Error(`Unexpected synthetic fetch: ${url.pathname}`);

View File

@@ -0,0 +1,107 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");
const { test } = require("node:test");
const { loadBackground } = require("./support/characterization.cjs");
function setup() {
let now = 100000, hours = 24, user = "a@example.invalid";
const factory = new vm.Script(fs.readFileSync("src/features/preferences/request-cache.js", "utf8") + "\ncreateFormRequestCache;").runInNewContext({ Date: { now: () => now } });
const cache = factory({ getHours: () => hours, getUserId: () => user });
return { cache, advance: value => { now += value; }, hours: value => { hours = value; }, user: value => { user = value; } };
}
const message = (name, fields = {}) => ({ type: `opportunitiesExtension.${name}`, ...fields });
test("all four form query families reuse successes with parameter-specific keys and live duration", async () => {
const h = setup(); let calls = 0;
const fetch = async request => { calls++; assert.equal(request.cacheMaxAgeMs, 24 * 3600000); return { ok: true, items: [{ name: "Original" }], debugRequests: [{ name: "network" }] }; };
for (const name of ["requestWeekDetails", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType", "requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"]) {
const msg = message(name, { id: 1 });
const result = await h.cache.send(msg, fetch); result.items[0].name = "Edited";
const cached = await h.cache.send(msg, fetch);
assert.equal(cached.fromCache, true); assert.equal(cached.items[0].name, "Original"); assert.equal(cached.debugRequests.length, 0);
await h.cache.send(message(name, { id: 2 }), fetch);
}
assert.equal(calls, 24);
h.advance(2 * 3600000); h.hours(1);
await h.cache.send(message("requestWeekDetails", { id: 1 }), async request => { calls++; assert.equal(request.cacheMaxAgeMs, 3600000); return { ok: true }; });
assert.equal(calls, 25);
});
test("failed results are never cached and identity changes discard previous responses", async () => {
const h = setup(); let calls = 0;
const send = async () => { calls++; return { ok: calls > 1 }; };
const msg = message("requestLookupTaskType");
await h.cache.send(msg, send); await h.cache.send(msg, send); await h.cache.send(msg, send); assert.equal(calls, 2);
h.user("b@example.invalid"); await h.cache.send(msg, send); assert.equal(calls, 3);
h.user(""); await h.cache.send(msg, send); await h.cache.send(msg, send); assert.equal(calls, 5);
});
test("writes and token requests always execute; successful saves and Refresh all invalidate queries", async () => {
for (const name of ["requestTimeEntriesSave", "requestTimeEntriesDelete", "requestMgmtAction", "requestPostServiceRequest", "requestPostInternalServiceRequest", "requestWorkloadForecastUpdate", "invalidateProductsCache"]) {
const h = setup(); let queries = 0, writes = 0;
const query = message("requestActionFindLookup");
const queryTransport = async () => { queries++; return { ok: true }; };
await h.cache.send(query, queryTransport);
for (let i = 0; i < 2; i++) await h.cache.send(message(name, { phase: "confirm" }), async () => { writes++; return { ok: true }; });
await h.cache.send(query, queryTransport);
assert.equal(writes, 2); assert.equal(queries, 2);
}
const h = setup(); let calls = 0;
for (let i = 0; i < 2; i++) await h.cache.send(message("requestSrTokenCatalog"), async () => { calls++; return { ok: true }; });
assert.equal(calls, 2);
});
test("reopening workloads reuses account cache across token renewals and keeps original persistent expiry", async () => {
const h = setup(); let calls = 0;
h.hours(1);
const transport = async () => { calls++; return { ok: true, workloads: [{ id: 1, forecast: [] }], cachedAt: 50000, fromCache: true }; };
const msg = message("requestWorkloads", { customerId: 123, principal: "a@example.invalid", authorization: { value: "old-token" } });
const first = await h.cache.send(msg, transport);
first.workloads[0].forecast.push({ month: 1 });
const second = await h.cache.send({ ...msg, customerId: "123", cacheMaxAgeMs: 3600000, authorization: { value: "new-token" } }, transport);
assert.equal(calls, 1);
assert.equal(second.fromCache, true);
assert.equal(second.cachedAt, 50000);
assert.equal(second.workloads[0].forecast.length, 0);
h.advance(3550000);
await h.cache.send(msg, transport);
assert.equal(calls, 2, "expires from persistent fetchedAt rather than modal opening time");
await h.cache.send({ ...msg, customerId: 456 }, transport);
assert.equal(calls, 3);
h.user("b@example.invalid");
await h.cache.send({ ...msg, principal: "b@example.invalid" }, transport);
assert.equal(calls, 4);
});
test("workload cache survives failed updates but invalidates after success and Refresh all", async () => {
const h = setup(); let calls = 0;
const msg = message("requestWorkloads", { customerId: 123 });
const transport = async () => { calls++; return { ok: true, workloads: [] }; };
await h.cache.send(msg, transport);
await h.cache.send(message("requestWorkloadForecastUpdate"), async () => ({ ok: false }));
await h.cache.send(msg, transport);
assert.equal(calls, 1);
await h.cache.send(message("requestWorkloadForecastUpdate"), async () => ({ ok: true }));
await h.cache.send(msg, transport);
assert.equal(calls, 2);
await h.cache.send(message("invalidateProductsCache"), async () => ({ ok: true }));
await h.cache.send(msg, transport);
assert.equal(calls, 3);
});
test("concurrent queries share work but old responses cannot repopulate an invalidated cache", async () => {
const h = setup(); let resolve, calls = 0;
const msg = message("requestLookupTypePlatform");
const transport = () => { calls++; return new Promise(done => { resolve = done; }); };
const first = h.cache.send(msg, transport), second = h.cache.send(msg, transport);
assert.equal(calls, 1); h.cache.invalidate(); resolve({ ok: true, items: [] }); await Promise.all([first, second]);
await h.cache.send(msg, async () => { calls++; return { ok: true }; }); assert.equal(calls, 2);
});
test("resource user respects duration and Refresh all invalidation", async () => {
let calls = 0;
const app = loadBackground({ fetch: async () => { calls++; return new Response(JSON.stringify({ resourcePartyId: 123 })); } });
const msg = message("requestResourceUser", { emailAddress: "a@example.invalid", cacheMaxAgeMs: 3600000 });
await app.dispatch(msg); await app.dispatch(msg); assert.equal(calls, 1);
app.advance(3600000); await app.dispatch(msg); assert.equal(calls, 2);
await app.dispatch(message("invalidateProductsCache")); await app.dispatch(msg); assert.equal(calls, 3);
});
test("Preferences displays generic cache duration while preserving the saved preference key", () => {
const source = fs.readFileSync("src/features/preferences/view.js", "utf8");
assert.match(source, /cacheLabel.textContent = "Cache duration"/);
assert.doesNotMatch(source, /Product cache duration/);
assert.match(source, /cacheSelect.name = "opptyProductsCacheHours"/);
});

View File

@@ -0,0 +1,145 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const vm = require("node:vm");
const path = require("node:path");
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/internal-sr-session.js"), "utf8")}\ncreateInternalSrSession`);
function setup(query, options = {}) {
let elapsed = 0;
const frames = [], removed = [], delays = [], stopped = [];
const body = { append(frame) { frame.initialSrc = frame.src; frames.push(frame); } };
const document = { body, createElement(tag) {
assert.equal(tag, "iframe");
return { contentWindow: { stop() { stopped.push(true); } }, style: {}, attributes: {}, listeners: {}, setAttribute(k, v) { this.attributes[k] = v; },
addEventListener(k, fn) { this.listeners[k] = fn; }, remove() { removed.push(this); } };
} };
const api = factory({ ...options, query, environment: { document, performance: { now: () => elapsed }, setTimeout: (fn, ms) => { delays.push(ms); elapsed += ms; return setImmediate(fn); } } });
return { api, frames, removed, delays, stopped };
}
test("lookup requests recover together using the configured page and preserve each result", async () => {
let authenticated = false;
let calls = 0;
const h = setup(async message => {
if (++calls <= 4) return { ok: false, httpStatus: 401 };
authenticated = true;
return { ok: true, items: [message.type] };
}, { sessionUrl: "https://eeho.fa.us2.oraclecloud.com/?view=Show%20Service%20Request", sessionLabel: "Service Request" });
const types = ["pillar", "language", "platform", "services"];
const results = await Promise.all(types.map(type => h.api.request({ type })));
assert.deepEqual(results, types.map(type => [type]));
assert.equal(authenticated, true);
assert.equal(h.frames.length, 1);
assert.match(h.frames[0].initialSrc, /Show%20Service%20Request$/);
assert.equal(h.frames[0].title, "Service Request session initialization");
assert.equal(h.removed.length, 1);
assert.equal(h.stopped.length, 1);
});
test("401 initializes a hidden iframe in the calling document and removes it on success", async () => {
let calls = 0;
const h = setup(async () => ++calls < 3 ? { ok: false, httpStatus: 401 } : { ok: true, items: [{ SrId: 1 }] });
const items = await h.api.request("1");
assert.equal(items[0].SrId, 1);
assert.equal(h.frames.length, 1);
const frame = h.frames[0];
assert.equal(frame.tabIndex, -1);
assert.equal(frame.attributes["aria-hidden"], "true");
assert.match(frame.style.cssText, /pointer-events:none/);
assert.ok(!frame.attributes.sandbox.includes("allow-top-navigation"));
assert.ok(frame.initialSrc.endsWith("&view=showisr"));
assert.equal(frame.src, "about:blank");
assert.equal(h.stopped.length, 1);
assert.deepEqual(h.delays, [250]);
assert.equal(h.removed[0], frame);
});
test("an immediately available session stops the iframe without waiting for load or a timer", async () => {
let calls = 0;
const h = setup(async () => ++calls === 1 ? { ok: false, httpStatus: 401 } : { ok: true, items: [] });
await h.api.request("1");
assert.equal(calls, 2);
assert.equal(h.delays.length, 0);
assert.equal(h.stopped.length, 1);
assert.equal(h.removed.length, 1);
});
test("lookup 401 errors from older background responses still initialize the session", async () => {
for (const failure of [
{ ok: false, error: "requestSrLookupLanguage failed with status 401." },
{ ok: false, debugRequests: [{ httpStatus: 401 }] },
{ ok: false, httpStatus: "401" }
]) {
let calls = 0;
const h = setup(async () => ++calls <= 2 ? failure : { ok: true, items: ["ready"] });
assert.deepEqual(await h.api.request("language"), ["ready"]);
assert.equal(h.frames.length, 1);
assert.equal(h.removed.length, 1);
}
});
test("a lookup that still returns 401 after shared recovery initializes its own session", async () => {
const calls = new Map();
const h = setup(async id => {
const count = (calls.get(id) || 0) + 1;
calls.set(id, count);
return count <= (id === "language" ? 1 : 2)
? { ok: false, httpStatus: 401 } : { ok: true, items: [id] };
});
assert.deepEqual(await Promise.all([h.api.request("language"), h.api.request("platform")]), [["language"], ["platform"]]);
assert.equal(h.frames.length, 2);
assert.equal(h.removed.length, 2);
});
test("cross-origin redirects still allow iframe removal when stop is blocked", async () => {
let calls = 0;
const h = setup(async () => {
if (++calls === 1) return { ok: false, httpStatus: 401 };
h.frames[0].contentWindow.stop = () => { throw new Error("Cross-origin"); };
return { ok: true, items: [] };
});
await h.api.request("1");
assert.equal(h.frames[0].src, "about:blank");
assert.equal(h.removed.length, 1);
});
test("concurrent queries share one iframe without mixing SR results", async () => {
let calls = 0;
const h = setup(async id => ++calls <= 2 ? { ok: false, httpStatus: 401 } : { ok: true, items: [id] });
const results = await Promise.all([h.api.request("1"), h.api.request("2")]);
assert.deepEqual(results, [["1"], ["2"]]);
assert.equal(h.frames.length, 1);
assert.equal(h.removed.length, 1);
});
test("normal success does not mount an iframe; errors and timeout always clean it up", async () => {
const success = setup(async () => ({ ok: true, items: [] }));
await success.api.request("1");
assert.equal(success.frames.length, 0);
for (const status of [401, 500]) {
let calls = 0;
const h = setup(async () => ({ ok: false, httpStatus: ++calls === 1 ? 401 : status }));
await assert.rejects(h.api.request("1"));
if (status === 401) {
assert.ok(calls > 31);
assert.equal(h.delays.reduce((sum, ms) => sum + ms, 0), 60000);
} else assert.equal(calls, 2);
assert.equal(h.removed.length, 1);
}
});
test("slow session initialization can succeed after the old thirty-attempt limit", async () => {
let calls = 0;
const h = setup(async () => ++calls < 45 ? { ok: false, httpStatus: 401 } : { ok: true, items: ["ready"] });
assert.deepEqual(await h.api.request("1"), ["ready"]);
assert.equal(calls, 45);
assert.equal(h.removed.length, 1);
assert.ok(h.delays.reduce((sum, ms) => sum + ms, 0) < 60000);
});
test("iframe load alone never confirms authentication", async () => {
let calls = 0;
const h = setup(async () => {
if (++calls > 1) h.frames[0].listeners.load();
return { ok: false, httpStatus: 401 };
});
await assert.rejects(h.api.request("1"), /HTTP 401.*load event, but authentication was not confirmed/);
assert.equal(h.removed.length, 1);
});

571
tests/manageTime.test.cjs Normal file
View File

@@ -0,0 +1,571 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const vm = require("node:vm");
const { loadBackground } = require("./support/characterization.cjs");
const factory = (file, name) => new vm.Script(fs.readFileSync(file, "utf8") + `\n${name};`).runInNewContext();
const dates = factory("src/shared/time-dates.js", "createTimeDates")();
const week = () => Array.from({ length: 7 }, (_, index) => ({ Id: 2927 + index, WeekId: 419, Date: `${10 + index}-OCT-2026`, EndDate: "16-OCT-2026" }));
const flush = () => new Promise(resolve => setImmediate(resolve));
test("time dates use English API dates and calendar-day arithmetic across month/year boundaries", () => {
assert.equal(dates.toApi("2026-10-16"), "16-OCT-2026");
assert.equal(dates.shift("2026-12-28", 7), "2027-01-04");
assert.equal(dates.shift("2026-03-03", -7), "2026-02-24");
assert.equal(dates.fromApi("10-OCT-2026").getUTCDay(), 6);
assert.equal(dates.fromApi("31-FEB-2026"), null);
assert.throws(() => dates.toApi("2026-02-30"));
});
for (const family of ["chrome", "firefox"]) test(`${family}: week details use Session, include cookies and preserve debug response`, async () => {
const calls = [];
const app = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
return new Response(JSON.stringify(week()), { headers: { "content-type": "application/json" } });
} });
const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" });
assert.equal(result.ok, true); assert.equal(result.items.length, 7);
assert.match(calls[0].url, /oalset_timeentrymobile\/live;profile=PROD\/.*weekDetails\?dateValue=16-OCT-2026$/);
assert.equal(calls[0].options.headers.Authorization, "Session");
assert.equal(calls[0].options.credentials, "include"); assert.equal(calls[0].options.method, "GET");
assert.equal(result.debugRequests[0].name, "requestWeekDetails");
assert.match(result.debugRequests[0].responseBody, /10-OCT-2026/);
assert.equal((await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "invalid" })).ok, false);
assert.equal(calls.length, 1);
});
test("week errors return status and debug logs without masking failure", async () => {
const app = loadBackground({ fetch: async () => new Response("Unauthorized", { status: 401 }) });
const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" });
assert.equal(result.ok, false); assert.equal(result.httpStatus, 401); assert.equal(result.debugRequests.length, 1);
});
function setup(requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [], requestEntries = async () => [], localStorage, requestSave, requestDelete) {
let document;
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
replaceChildren(...children) { this.children = []; this.append(...children); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { document.activeElement = this; }
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
click() { if (!this.disabled) this.listeners.click?.(); }
}
const host = new Element("host");
document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag), getElementById: () => host };
const calls = [], notifications = [];
const searchableSelect = factory("src/features/opportunities/searchable-select.js", "createSearchableSelect")({ environment: { document } });
const app = factory("src/features/manage-time/view.js", "createManageTime")({ MODAL_ID: "host", environment: { document, localStorage }, searchableSelect, dates, requestServices, requestExpand, requestTaskTypes, requestEntries, requestSave, requestDelete,
notify: (message, isError = false) => notifications.push({ message, isError }),
requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) });
app.setReady(true);
const walk = el => [el, ...el.children.flatMap(walk)];
const nodes = () => walk(host);
const find = label => nodes().find(el => el.attributes["aria-label"] === label);
return { app, calls, nodes, find, host, notifications };
}
test("persisted deletions wait for Save, stay scoped to their week and allow a deletion-only save", async () => {
const deleted = [];
const h = setup(undefined, undefined, undefined, async () => [{ id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, tag: "SERVICE" }], undefined, undefined, async id => deleted.push(id));
h.app.open(); h.calls[0].resolve(week()); await flush();
h.find("Delete row 1").click(); assert.deepEqual(deleted, []); assert.equal(h.find("Save").disabled, false);
h.find("Next week").click();
const otherWeek = week().map(day => ({ ...day, Date: dates.toApi(dates.shift(dates.fromApi(day.Date).toISOString().slice(0, 10), 7)), EndDate: "23-OCT-2026", WeekId: 420 }));
h.calls[1].resolve(otherWeek); await flush(); assert.equal(h.find("Save").disabled, true);
h.find("Previous week").click(); h.calls[2].resolve(week()); await flush();
assert.equal(h.find("Service Request, row 1"), undefined);
h.find("Save").click(); await flush();
assert.deepEqual(deleted, ["7"]); assert.equal(h.find("Save").disabled, true);
assert.deepEqual(h.notifications, [{ message: "1 time entry deleted.", isError: false }]);
});
test("deleting new rows is local; failed deletions retain only unfinished IDs", async () => {
const deleted = []; let fail = true;
const h = setup(undefined, undefined, undefined, async () => [7, 8].map(id => ({ id, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, tag: "SERVICE" })), undefined, undefined, async id => {
deleted.push(id); if (id === "8" && fail) throw new Error("HTTP 403");
});
h.app.open(); h.calls[0].resolve(week()); await flush();
h.find("Add").click(); h.find("Delete row 3").click();
assert.equal(h.find("Save").disabled, true); assert.deepEqual(deleted, []);
h.find("Delete row 1").click(); h.find("Delete row 1").click();
h.find("Save").click(); h.find("Save").click(); await flush();
assert.deepEqual(deleted, ["7", "8"]); assert.equal(h.find("Save").disabled, false);
assert.deepEqual(h.notifications, [{ message: "1 deleted. HTTP 403", isError: true }]);
fail = false; h.find("Save").click(); await flush();
assert.deepEqual(deleted, ["7", "8", "8"]); assert.equal(h.find("Save").disabled, true);
});
for (const family of ["chrome", "firefox"]) test(`${family}: DELETE uses the saved ID, identical write headers and a narrow Origin rule`, async () => {
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
const calls = [];
const app = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options }); return new Response(null, { status: 204 });
} });
app.responseHeaderListeners[0].listener({ url: `${origin}/ic/builder/rt/oalset_timeentrymobile/live/test`, incognito: false,
responseHeaders: [{ name: "x-appbuilder-client-id", value: "delete-client" }] });
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesDelete", id: 9339403 });
assert.equal(result.ok, true); assert.equal(calls.length, 1);
assert.match(calls[0].url, /\/mgmt\/timeEntries\/9339403$/);
assert.equal(calls[0].options.method, "DELETE"); assert.equal(calls[0].options.body, undefined);
assert.deepEqual({ ...calls[0].options.headers }, { Accept: "application/json", "Content-Type": "application/json", Authorization: "Session", Origin: origin, "x-appbuilder-client-id": "delete-client" });
const rule = app.headerRules[0].addRules[0];
assert.equal(rule.id, 7205); assert.deepEqual(rule.condition.requestMethods, ["delete"]);
assert.equal(new RegExp(rule.condition.regexFilter).test(calls[0].url), true);
assert.equal(new RegExp(rule.condition.regexFilter).test(calls[0].url + "0"), false);
const invalid = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesDelete", id: "../other" });
assert.equal(invalid.ok, false); assert.equal(calls.length, 1);
});
test("Save validates service rows, posts once, then puts changes with the returned id", async () => {
const calls = []; let finish;
const h = setup(undefined, undefined, undefined, undefined, undefined, (method, items) => {
calls.push({ method, items }); return new Promise(resolve => { finish = () => resolve(items.map(item => ({ ...item, id: 123 }))); });
});
h.app.open(); h.calls[0].resolve(week()); await flush();
assert.equal(h.find("Save").disabled, true);
for (const [label, value] of [["Service Request", "SR001"], ["Activity", "Solution Design"], ["Task Type", "PREP"]]) {
const input = h.find(`${label}, row 1`); input.value = value; input.listeners.change();
assert.equal(h.find("Save").disabled, true);
}
const hours = h.find("Hours 12-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input();
assert.equal(h.find("Save").disabled, false);
h.find("Save").click(); h.find("Save").click();
assert.equal(calls.length, 1); assert.equal(calls[0].method, "POST");
assert.equal(calls[0].items[0].mondayTaskHours, 2); assert.equal(calls[0].items[0].saturdayTaskHours, 0);
assert.equal(calls[0].items[0].id, undefined); assert.equal(calls[0].items[0].weekId, 419);
finish(); await flush(); assert.equal(h.find("Save").disabled, true);
assert.deepEqual(h.notifications, [{ message: "1 time entry saved.", isError: false }]);
const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1");
notes.value = "Updated"; notes.listeners.input(); h.find("Save").click();
assert.equal(calls[1].method, "PUT"); assert.equal(calls[1].items[0].id, 123);
finish(); await flush(); assert.equal(h.find("Save").disabled, true);
assert.equal(h.notifications.length, 2); assert.equal(h.notifications[1].isError, false);
});
test("mixed saves retain successful PUTs when POST fails and never repost a saved row without an id", async () => {
const calls = [];
const h = setup(undefined, undefined, undefined, async () => [{ id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", tag: "SERVICE", mondayTaskHours: 2 }], undefined,
async (method, items) => { calls.push({ method, items }); if (method === "POST" && calls.length === 2) throw new Error("Post failed"); return null; });
h.app.open(); h.calls[0].resolve(week()); await flush();
const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1");
notes.value = "Changed"; notes.listeners.input();
h.find("Copy row 1").click();
const copiedSr = h.find("Service Request, row 2"); copiedSr.value = "SR002"; copiedSr.listeners.change();
const copiedActivity = h.find("Activity, row 2"); copiedActivity.value = "Design"; copiedActivity.listeners.change();
h.find("Save").click(); await flush();
assert.deepEqual(calls.map(call => call.method), ["PUT", "POST"]);
assert.equal(h.find("Save").disabled, false);
h.find("Save").click(); await flush();
assert.deepEqual(calls.map(call => call.method), ["PUT", "POST", "POST"]);
const copyNotes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 2");
copyNotes.value = "Further edit"; copyNotes.listeners.input();
assert.equal(h.find("Save").disabled, true);
assert.ok(h.nodes().some(node => node.attributes.role === "alert" && node.textContent.includes("IDs could not be confirmed")));
});
test("Non Service saves without activity and failed saves keep edited values", async () => {
const calls = [];
const h = setup(undefined, undefined, undefined, undefined, undefined, async (method, items) => { calls.push({ method, items }); throw new Error("HTTP 403"); });
h.app.open(); h.calls[0].resolve(week()); await flush(); h.find("Add (Non service)").click();
const type = h.find("Task Type, row 4"); type.value = "TRAIN"; type.listeners.change();
const hours = h.find("Hours 10-OCT-2026, row 4"); hours.value = "3"; hours.valueAsNumber = 3; hours.listeners.input();
assert.equal(h.find("Save").disabled, false);
hours.value = "25"; hours.valueAsNumber = 25; hours.listeners.input();
assert.equal(hours.value, "3"); assert.equal(h.find("Save").disabled, false);
assert.deepEqual(h.notifications.at(-1), { message: "Enter hours between 0 and 24.", isError: true });
hours.value = "3"; hours.valueAsNumber = 3; hours.listeners.input(); h.find("Save").click(); await flush();
assert.equal(calls[0].items[0].tag, "NON_SERVICE"); assert.equal(calls[0].items[0].srNumber, "non-service-sr");
assert.equal(calls[0].items[0].activityCode, "");
assert.equal(h.find("Hours 10-OCT-2026, row 4").value, "3");
assert.equal(h.find("Save").disabled, false);
assert.ok(h.nodes().some(node => node.attributes.role === "alert" && node.textContent.includes("403")));
});
for (const method of ["POST", "PUT"]) test(`time entry ${method} uses Session and the current resource with a JSON array`, async () => {
let posted;
const app = loadBackground({ fetch: async (url, options) => {
if (url.includes("resourceUsers?")) return new Response(JSON.stringify({ resourcePartyId: 123 }), { headers: { "x-appbuilder-client-id": "time-save-client" } });
assert.match(url, /\/mgmt\/timeEntries$/); assert.equal(options.method, method);
assert.equal(options.headers.Authorization, "Session"); assert.equal(options.headers["Content-Type"], "application/json");
assert.equal(options.headers.Origin, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com");
assert.equal(options.headers["x-appbuilder-client-id"], "time-save-client");
posted = JSON.parse(options.body); return new Response(null, { status: 204 });
} });
const item = { id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", weekId: 419, tag: "SERVICE", mondayTaskHours: 2,
tuesdayTaskHours: 0, wednesdayTaskHours: 0, thursdayTaskHours: 0, fridayTaskHours: 0, saturdayTaskHours: 0, sundayTaskHours: 0 };
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesSave", method, items: [item], emailAddress: "test@oracle.com" });
assert.equal(result.ok, true); assert.equal(posted[0].resourceId, "123"); assert.equal(posted[0].id, method === "PUT" ? 7 : undefined);
const rule = app.headerRules.find(update => update.addRules.some(rule => rule.id === 7204)).addRules[0];
assert.deepEqual(rule.condition.requestMethods, ["post", "put"]);
assert.equal(rule.condition.initiatorDomains.length, 1);
assert.equal(rule.action.requestHeaders[0].value, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com");
assert.equal(new RegExp(rule.condition.regexFilter).test("https://example.com/mgmt/timeEntries"), false);
});
test("time entry headers reuse IDs captured from Time Entry responses and session storage", async () => {
const stored = {};
const runtimeApi = { runtime: { getURL: () => "chrome-extension://test/" },
storage: { session: { set: async values => Object.assign(stored, values), get: async () => stored } },
declarativeNetRequest: { updateSessionRules: async () => {} } };
const createHeaders = new vm.Script(fs.readFileSync("src/auth/sr-request-headers.js", "utf8") + "\ncreateSrRequestHeaders;").runInNewContext({ URL });
const headers = createHeaders({ runtimeApi });
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries";
headers.observeResponseHeaders(`${url}/weekDetails?dateValue=16-OCT-2026`, [{ name: "x-appbuilder-client-id", value: "time-entry-client" }]);
assert.equal((await headers.timeEntriesHeaders(url))["x-appbuilder-client-id"], "time-entry-client");
const restarted = createHeaders({ runtimeApi });
assert.equal((await restarted.timeEntriesHeaders(url))["x-appbuilder-client-id"], "time-entry-client");
});
test("hour inputs accept inclusive 0..24 and reject out-of-range or nonnumeric values", async () => {
const h = setup(); h.app.open(); h.calls[0].resolve(week()); await flush();
const input = h.find("Hours 10-OCT-2026, row 1");
for (const value of [0, 24]) {
input.value = String(value); input.valueAsNumber = value; input.listeners.input();
assert.equal(input.value, String(value));
}
for (const value of [-1, 25, 100, Infinity, NaN]) {
input.value = String(value); input.valueAsNumber = value; input.listeners.input();
assert.equal(input.value, "24"); assert.equal(h.notifications.at(-1).isError, true);
}
input.value = ""; input.valueAsNumber = NaN; input.listeners.input();
assert.equal(input.value, "");
});
test("time entry writes reject out-of-range hours before making a network request", async () => {
let calls = 0;
const app = loadBackground({ fetch: async () => { calls++; throw new Error("Unexpected fetch"); } });
for (const value of [-1, 25]) {
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesSave", method: "POST", items: [{
srNumber: "SR001", activityCode: "Design", taskType: "PREP", tag: "SERVICE", weekId: 419,
saturdayTaskHours: value, sundayTaskHours: 0, mondayTaskHours: 1, tuesdayTaskHours: 0, wednesdayTaskHours: 0, thursdayTaskHours: 0, fridayTaskHours: 0
}] });
assert.equal(result.ok, false);
}
assert.equal(calls, 0);
});
test("duplicate SR selection restores the previous selection and highlights the existing row", async () => {
const h = setup(async () => [{ srNumber: "SR001", srTitle: "One" }, { srNumber: "SR002", srTitle: "Two" }]);
h.app.open(); h.calls[0].resolve(week()); await flush();
function select(row, value) { const input = h.find(`Service Request, row ${row}`); input.value = value; input.listeners.change(); }
select(1, "SR001"); select(2, "SR002"); select(2, "SR001");
assert.equal(h.find("Service Request, row 2").value, "SR002");
const highlighted = h.nodes().filter(el => el.className === "oe-time-duplicate-row");
assert.equal(highlighted.length, 1);
assert.equal(h.find("Service Request, row 1").parent.parent, highlighted[0]);
assert.equal(h.notifications.at(-1).isError, true);
highlighted[0].listeners.animationend();
assert.equal(highlighted[0].className, "");
h.find("Delete row 1").click(); select(1, "SR001");
assert.equal(h.find("Service Request, row 1").value, "SR001");
const css = fs.readFileSync("src/features/manage-time/styles.css", "utf8");
assert.match(css, /oe-time-duplicate-fade 5\.5s/);
assert.match(css, /90\.909%/);
});
test("reference date persists manual and weekly changes across modal and page reopening", () => {
const saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const h = setup(undefined, undefined, undefined, undefined, storage);
h.app.open();
const date = h.find("Reference date"); date.value = "2026-10-09"; date.listeners.change();
h.app.close(); h.app.open();
assert.equal(h.find("Reference date").value, "2026-10-09");
h.find("Next week").click();
const next = setup(undefined, undefined, undefined, undefined, storage); next.app.open();
assert.equal(next.calls[0].date, "2026-10-16");
const invalid = next.find("Reference date"); invalid.value = ""; invalid.listeners.change();
next.app.close(); next.app.open();
assert.equal(next.find("Reference date").value, "2026-10-16");
});
test("invalid stored date falls back to today and unavailable storage retains the in-memory date", () => {
const h = setup(undefined, undefined, undefined, undefined, { getItem: () => "invalid", setItem() { throw new Error("Blocked"); } });
h.app.open(); assert.equal(h.calls[0].date, dates.today());
const date = h.find("Reference date"); date.value = "2026-10-09"; date.listeners.change();
h.app.close(); h.app.open();
assert.equal(h.find("Reference date").value, "2026-10-09");
});
test("loading renders three skeleton rows until entries resolve, then restores empty input rows", async () => {
let resolveEntries;
const h = setup(undefined, undefined, undefined, () => new Promise(resolve => { resolveEntries = resolve; }));
h.app.open();
const skeletons = () => h.nodes().filter(el => el.className === "oe-time-skeleton-row");
assert.equal(skeletons().length, 3);
assert.ok(skeletons().every(row => row.children.length === 13));
assert.equal(h.find("Weekly time entries").attributes["aria-hidden"], "true");
assert.equal(h.find("Add").disabled, true);
h.calls[0].resolve(week()); await flush();
assert.equal(skeletons().length, 3);
resolveEntries([]); await flush();
assert.equal(skeletons().length, 0);
assert.equal(h.find("Weekly time entries").attributes["aria-hidden"], "false");
assert.ok(h.find("Service Request, row 3"));
assert.equal(h.find("Add").disabled, false);
});
test("time entry errors allow retry and stale weeks cannot replace current entries", async () => {
const pending = [];
const h = setup(undefined, undefined, undefined, weekId => new Promise((resolve, reject) => pending.push({ weekId, resolve, reject })));
h.app.open(); h.calls[0].resolve(week()); await flush();
pending[0].reject(new Error("Time entries unavailable")); await flush();
assert.equal(h.find("Retry").hidden, false);
assert.equal(h.find("Add").disabled, true);
h.find("Retry").click(); h.calls[1].resolve(week()); await flush();
h.find("Next week").click(); h.calls[2].resolve(week().map(day => ({ ...day, WeekId: 420 }))); await flush();
pending[2].resolve([{ srNumber: "SR002", activityCode: "Current", tag: "SERVICE" }]); await flush();
pending[1].resolve([{ srNumber: "SR001", activityCode: "Stale", tag: "SERVICE" }]); await flush();
assert.equal(h.find("Service Request, row 1").value, "SR002");
assert.equal(h.find("Activity, row 1").value, "Current");
});
test("saved entries hydrate selections, weekday hours and notes, preserving drafts", async () => {
const calls = [];
const h = setup(undefined, undefined, async () => [{ lookUpCode_c: "PREP", lookUpMeaning_c: "Preparation", tags_c: "SERVICE" }], async weekId => {
calls.push(weekId);
return [{ id: 9339403, srNumber: "SR0001502709", taskType: "PREP", activityCode: "Solution Design", tag: "SERVICE", mondayTaskHours: 2, saturdayTaskHours: 3, sundayTaskHours: 0, notes: "Saved notes" },
{ id: 2, srNumber: "non-service-sr", taskType: "TRAIN", activityCode: "Training", tag: "NON_SERVICE", fridayTaskHours: 4 }];
});
h.app.open(); h.calls[0].resolve(week()); await flush();
assert.deepEqual(calls, [419]);
assert.equal(h.find("Service Request, row 1").value, "SR0001502709");
assert.equal(h.find("Activity, row 1").value, "Solution Design");
assert.equal(h.find("Task Type, row 1").value, "PREP");
assert.equal(h.find("Hours 12-OCT-2026, row 1").value, "2");
assert.equal(h.find("Hours 10-OCT-2026, row 1").value, "3");
assert.equal(h.find("Hours 11-OCT-2026, row 1").value, "0");
assert.equal(h.find("Hours 16-OCT-2026, row 2").value, "4");
assert.ok(h.find("Non Service, row 2"));
const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1");
assert.equal(notes.value, "Saved notes");
notes.value = "Edited notes"; notes.listeners.input();
h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
assert.equal(calls.length, 1);
assert.equal(h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1").value, "Edited notes");
});
for (const family of ["chrome", "firefox"]) test(`${family}: time entries use the resource party, selected week, Session and ascending created date`, async () => {
const calls = [];
const app = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
return new Response(JSON.stringify(url.includes("resourceUsers?") ? { resourcePartyId: 123 } : [{ id: 7, srNumber: "SR001" }]));
} });
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntries", weekId: 418, emailAddress: "test@oracle.com" });
assert.equal(result.ok, true);
const call = calls.find(call => call.url.includes("timeEntries?"));
assert.match(call.url, /timeEntries\?resourceId=123&sortBy=createdDate&sortOrder=ASC&weekId=418$/);
assert.equal(call.options.headers.Authorization, "Session");
assert.equal(call.options.credentials, "include");
assert.equal(result.items[0].id, 7);
});
test("autocomplete reopening highlights its saved option and filtering does not select another value", async () => {
const h = setup(undefined, undefined, async () => [
{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" },
{ lookUpCode_c: "PREP", lookUpMeaning_c: "Preparation", tags_c: "SERVICE" }
], async () => [{ srNumber: "SR001", taskType: "PREP", tag: "SERVICE" }]);
h.app.open(); h.calls[0].resolve(week()); await flush();
const input = h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === "Task Type, row 1");
input.click();
const list = input.parent.children[1];
assert.equal(list.children[0].attributes["aria-selected"], "false");
assert.equal(list.children[1].attributes["aria-selected"], "true");
assert.equal(input.attributes["aria-activedescendant"], list.children[1].id);
input.value = "Config"; input.listeners.input();
assert.equal(list.children[0].attributes["aria-selected"], "false");
assert.equal(h.find("Task Type, row 1").value, "PREP");
input.listeners.blur(); input.click();
assert.equal(list.children[1].attributes["aria-selected"], "true");
});
test("Service Request tooltips use expanded fields in order and keep description separate", async () => {
const keys = ["SrNumber", "AccountPartyUniqueName", "OptyName_c", "OptyNumber_c", "OptyOwner_c", "OptyStatus_c", "ServiceNew_c", "ReportedByPartyUniqueName", "CountryText_c", "StatusCdMeaning", "AssigneePersonName", "ProblemDescription"];
const record = Object.fromEntries(keys.map(key => [key, key === "SrNumber" ? "SR001" : `Value ${key}`]));
record.ProblemDescription = "<b>Plain text</b>\nSecond line";
const h = setup(async () => [{ srNumber: "SR001", srTitle: "Example" }], async () => [record]);
h.app.open(); h.calls[0].resolve(week()); await flush();
const input = h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === "Service Request, row 1");
input.click();
const list = input.parent.children[1], tooltip = input.parent.children[2];
list.children[0].listeners.mouseenter();
assert.equal(tooltip.hidden, false);
const fields = tooltip.children[0].children;
assert.deepEqual(fields.map(field => field.children[0].textContent), ["SR number", "Account", "Opportunity", "Opty number", "Opty owner", "Opty status", "Service", "Requested by", "Country", "Status", "Service lead", "Description"]);
assert.deepEqual(fields.map(field => field.children[1].textContent), keys.map(key => record[key]));
assert.equal(fields[11].className, "oe-time-sr-description");
assert.equal(fields[11].children[1].textContent, record.ProblemDescription);
assert.equal(fields[11].children[1].children.length, 0);
input.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
assert.equal(tooltip.hidden, true);
assert.equal(h.nodes().filter(el => el.attributes.role === "tooltip").length, 3);
});
test("Manage Time autocompletes search and select SR, dependent Activity and filtered Task Type", async () => {
const h = setup(async () => [{ srNumber: "SR001", srTitle: "Customer migration" }],
async () => [{ SrNumber: "SR001", SRActivities_c: "Account Plan;Technical Support" }],
async () => [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Configuration", tags_c: "SERVICE" },
{ lookUpCode_c: "TRAIN", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }]);
h.app.open(); h.calls[0].resolve(week()); await flush();
const combo = label => h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === label);
function choose(label, query) {
const input = combo(label); input.focus(); input.value = query; input.listeners.input();
const list = input.parent.children[1];
assert.equal(list.hidden, false);
assert.equal(list.children.length, 1);
input.listeners.keydown({ key: "Enter", preventDefault() {} });
}
assert.equal(h.find("Service Request, row 1").hidden, true);
assert.equal(combo("Activity, row 1").disabled, true);
choose("Service Request, row 1", "sr001");
assert.equal(combo("Service Request, row 1").value, "Customer migration (SR001)");
assert.equal(h.find("Service Request, row 1").value, "SR001");
assert.equal(combo("Activity, row 1").disabled, false);
choose("Activity, row 1", "technical");
choose("Task Type, row 1", "config");
assert.equal(h.find("Task Type, row 1").value, "CONFIG");
h.find("Copy row 1").click();
assert.equal(combo("Activity, row 4").value, "");
assert.equal(combo("Service Request, row 4").value, "");
h.find("Add (Non service)").click();
choose("Task Type, row 5", "train");
const ids = h.nodes().filter(el => el.attributes.role === "combobox").map(el => el.id);
assert.equal(new Set(ids).size, ids.length);
});
for (const family of ["chrome", "firefox"]) test(`${family}: Task Type lookup uses the exact endpoint, Session and logs its response`, async () => {
const app = loadBackground({ family, fetch: async (url, options) => {
assert.match(url, /oalset_timeentrymobile\/live;profile=PROD\/.*\/crm\/lookups\?lookupType=SCTA_TASK_TYPE&type=CUSTOM$/);
assert.equal(options.method, "GET"); assert.equal(options.credentials, "include");
assert.equal(options.headers.Authorization, "Session");
return new Response(JSON.stringify({ items: [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config" }] }));
} });
const result = await app.dispatch({ type: "opportunitiesExtension.requestLookupTaskType" });
assert.equal(result.ok, true); assert.equal(result.items[0].lookUpCode_c, "CONFIG");
assert.equal(result.debugRequests[0].name, "requestLookupTaskType");
});
test("Task Types load once per opening and retain selected codes when copying or navigating weeks", async () => {
let count = 0;
const h = setup(undefined, undefined, async () => { count++; return [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }]; });
h.app.open(); h.calls[0].resolve(week()); await flush();
const select = h.find("Task Type, row 1");
assert.equal(select.disabled, false); assert.equal(select.children[1].value, "CONFIG");
assert.equal(select.children[1].textContent, "Config"); select.value = "CONFIG"; select.listeners.change();
h.find("Copy row 1").click(); assert.equal(h.find("Task Type, row 4").value, "CONFIG");
h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
assert.equal(h.find("Task Type, row 1").value, "CONFIG"); assert.equal(count, 1);
});
test("Task Types match the exact row tag, including new and copied non-service rows", async () => {
const h = setup(undefined, undefined, async () => [
{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" },
{ lookUpCode_c: "TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" },
{ lookUpCode_c: "OTHER", lookUpMeaning_c: "Other", tags_c: "OTHER" },
{ lookUpCode_c: "UNTAGGED", lookUpMeaning_c: "Untagged" }
]);
h.app.open(); h.calls[0].resolve(week()); await flush();
assert.deepEqual(h.find("Task Type, row 1").children.map(item => item.value), ["", "CONFIG"]);
h.find("Add (Non service)").click();
const select = h.find("Task Type, row 4");
assert.deepEqual(select.children.map(item => item.value), ["", "TRAINING"]);
select.value = "TRAINING"; select.listeners.change(); h.find("Copy row 4").click();
assert.equal(h.find("Task Type, row 5").value, "TRAINING");
assert.deepEqual(h.find("Task Type, row 5").children.map(item => item.value), ["", "TRAINING"]);
});
test("Task Type stays disabled when no options match the row tag", async () => {
const h = setup(undefined, undefined, async () => [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }]);
h.app.open(); h.calls[0].resolve(week()); await flush(); h.find("Add (Non service)").click();
assert.equal(h.find("Task Type, row 4").disabled, true);
assert.equal(h.find("Task Type, row 4").children.length, 1);
});
test("Task Type failure can be retried and late responses cannot alter a closed modal", async () => {
let resolve;
let count = 0;
const h = setup(undefined, undefined, () => {
if (++count === 1) return Promise.reject(new Error("Lookup unavailable"));
return new Promise(done => { resolve = done; });
});
h.app.open(); h.calls[0].resolve(week()); await flush();
assert.equal(h.find("Task Type, row 1").disabled, true);
assert.equal(h.find("Retry Task Types").hidden, false);
h.find("Retry Task Types").click(); h.app.close(); resolve([]); await flush();
assert.equal(h.host.children.length, 0);
});
test("modal loads today, navigates seven days, ignores stale results and preserves drafts within the modal", async () => {
const h = setup(); h.app.open();
assert.equal(h.calls[0].date, dates.today());
const date = h.find("Reference date"); date.value = "2026-10-16"; date.listeners.change();
h.calls[1].resolve(week()); await flush();
assert.equal(h.nodes().filter(el => el.tag === "th").length, 13);
assert.equal(h.nodes().filter(el => el.type === "number").length, 21);
assert.equal(h.find("Save").disabled, true);
const hours = h.find("Hours 10-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input();
assert.equal(hours.step, "1");
assert.ok(h.nodes().some(el => el.tag === "strong" && el.textContent === "2h"));
h.find("Copy row 1").click(); assert.equal(h.nodes().filter(el => el.type === "number").length, 28);
h.find("Next week").click(); assert.equal(h.calls[2].date, "2026-10-23");
h.find("Previous week").click(); assert.equal(h.calls[3].date, "2026-10-16");
h.calls[3].resolve(week()); await flush();
assert.equal(h.find("Hours 10-OCT-2026, row 1").value, "2");
h.calls[2].reject(new Error("Stale error")); h.calls[0].resolve([]); await flush();
assert.ok(!h.host.textContent.includes("Stale error"));
h.app.close(); assert.equal(h.host.children.length, 0);
});
test("Manage Time is disabled until session ready, and activities correlate with selected SR", async () => {
const h = setup(async () => [{ srNumber: "SR1" }, { srNumber: "SR2" }], async () => [
{ SrNumber: "SR1", SRActivities_c: "Design;Sizing, Quoting or Configuration;Design" },
{ SrNumber: "SR2", SRActivities_c: "Review, Workshop" },
{ SrNumber: "non-service-sr", SRActivities_c: "Training;Meeting" }
]);
h.app.setReady(false); const trigger = h.app.createButton();
assert.equal(trigger.disabled, true); h.app.open(); assert.equal(h.calls.length, 0);
h.app.setReady(true); trigger.click(); h.calls[0].resolve(week()); await flush();
let select = h.find("Service Request, row 1"); select.value = "SR1"; select.listeners.change();
let activity = h.find("Activity, row 1");
assert.deepEqual(activity.children.map(el => el.textContent), ["Activity", "Design", "Sizing, Quoting or Configuration"]);
activity.value = "Design"; activity.listeners.change();
select = h.find("Service Request, row 1"); select.value = "SR2"; select.listeners.change();
activity = h.find("Activity, row 1"); assert.equal(activity.value, "");
assert.deepEqual(activity.children.map(el => el.textContent), ["Activity", "Review", "Workshop"]);
h.find("Add (Non service)").click(); assert.equal(h.find("Activity, row 4").children[1].value, "Training");
});
for (const count of [0, 49, 50, 51, 100, 101]) test(`expansion batches ${count} unique SRs and appends non-service exactly once`, async () => {
const queries = [];
const app = loadBackground({ fetch: async (url, options) => {
queries.push(new URL(url).searchParams.get("q"));
assert.equal(options.headers["vb-proxy-header-rest-framework-version"], "3");
assert.equal(options.headers.Authorization, "Session");
assert.equal(options.credentials, "include");
return new Response(JSON.stringify({ items: [] }));
} });
const numbers = Array.from({ length: count }, (_, i) => `SR${i + 1}`);
const result = await app.dispatch({ type: "opportunitiesExtension.requestServiceRequestExpand", srNumbers: [...numbers, ...numbers] });
assert.equal(result.ok, true);
assert.equal(queries.length, Math.floor(count / 50) + 1);
const batches = queries.map(query => [...query.matchAll(/'([^']+)'/g)].map(match => match[1]));
assert.deepEqual(batches.flat(), [...numbers, "non-service-sr"]);
assert.ok(batches.every(batch => batch.length <= 50));
});
test("modal supports retry and ignores completion after close", async () => {
const h = setup(); h.app.open(); h.calls[0].reject(new Error("Session expired")); await flush();
assert.equal(h.find("Retry").hidden, false); assert.ok(h.host.textContent.includes("Session expired"));
h.find("Retry").click(); h.app.close(); h.calls[1].resolve(week()); await flush();
assert.equal(h.host.children.length, 0);
});
test("active SRs load once per modal and notes preserve text, character count and collapse state", async () => {
const calls = [];
const h = setup(async weekId => { calls.push(weekId); return [{ srNumber: "SR1", srTitle: "Customer service" }]; });
h.app.open(); h.calls[0].resolve(week()); await flush();
const select = h.find("Service Request, row 1");
assert.equal(select.disabled, false); assert.equal(select.children[1].value, "SR1");
assert.equal(select.children[1].textContent, "Customer service (SR1)");
select.value = "SR1"; select.listeners.change();
h.find("Notes, row 1").click();
const notes = h.nodes().find(el => el.tag === "textarea");
assert.equal(notes.maxLength, 2000); notes.value = "Hello"; notes.listeners.input();
assert.ok(h.host.textContent.includes("1995 characters left"));
h.find("Collapse notes, row 1").click();
assert.equal(h.find("Notes, row 1").attributes["aria-expanded"], "false");
h.find("Copy row 1").click(); assert.equal(h.find("Service Request, row 4").value, "");
h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
assert.deepEqual(calls, [419]);
h.app.close(); h.app.open(); h.calls[2].resolve(week()); await flush();
assert.deepEqual(calls, [419, 419]);
});
for (const family of ["chrome", "firefox"]) test(`${family}: resource response is cached across concurrent calls and worker restarts`, async () => {
const calls = [], sessionStorage = {};
const fetch = async (url, options) => {
calls.push({ url, options });
return new Response(JSON.stringify(url.includes("resourceUsers?") ? { resourcePartyId: 123, partyName: "Test User" } : [{ srNumber: "SR1", srTitle: "Test" }]), { headers: { "content-type": "application/json" } });
};
const message = { type: "opportunitiesExtension.requestResourceUser", emailAddress: "test@example.invalid" };
const app = loadBackground({ family, fetch, sessionStorage });
const results = await Promise.all([app.dispatch(message), app.dispatch(message)]);
assert.equal(results[0].resource.resourcePartyId, 123); assert.equal(calls.length, 1);
const restarted = loadBackground({ family, fetch, sessionStorage });
assert.equal((await restarted.dispatch(message)).resource.partyName, "Test User"); assert.equal(calls.length, 1);
const active = await restarted.dispatch({ ...message, type: "opportunitiesExtension.requestActiveServiceRequests", weekId: 419 });
assert.equal(active.ok, true); assert.equal(active.items[0].srNumber, "SR1");
assert.equal(calls.length, 2); assert.match(calls[1].url, /activeServiceRequests\?resourceId=123&weekId=419$/);
assert.equal(calls[1].options.headers.Authorization, "Session");
await restarted.dispatch({ ...message, emailAddress: "other@example.invalid" }); assert.equal(calls.length, 3);
});

View File

@@ -0,0 +1,227 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { test } = require("node:test");
const factory = new vm.Script(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/calendar.js"), "utf8")}\ncreateOpportunityCalendar;`).runInNewContext();
const plain = value => JSON.parse(JSON.stringify(value));
const opportunity = (number, date) => ({ OptyNumber: number, Name: `<Opportunity ${number}>`, EffectiveDate: date,
ForecastGroup_c: "FORECAST", StatusCode: "OPEN", CustomerAccount: { PartyUniqueName: "Customer" } });
function setup(items = [opportunity("A", "2026-11-15")]) {
let document;
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
append(...nodes) { for (const child of nodes) { child.parent = this; this.children.push(child); } }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { document.activeElement = this; }
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
click() { this.listeners.click?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
}
const host = new Element("host");
document = { createElement: tag => new Element(tag), getElementById: () => host };
const state = { status: "ready", items };
const h = { state, filtered: items, version: 1, instances: [], loads: 0, host, document };
class Calendar {
constructor(element, options) { this.element = element; this.options = options; this.events = options.events; this.dates = []; h.instances.push(this); }
render() { this.rendered = true; }
destroy() { this.destroyed = true; }
updateSize() { this.resized = true; }
batchRendering(fn) { fn(); }
gotoDate(date) { this.dates.push(date); }
removeAllEvents() { this.events = []; }
addEventSource(events) { this.events = events; }
}
h.api = factory({ environment: { document }, loadLibrary: () => { h.loads++; return { Calendar }; },
getState: () => state, getItems: () => h.filtered, getVersion: () => h.version,
formatRevenue: () => "$100", formatDate: value => value.slice(0, 10), getOwnerName: () => "Owner",
createDetailLink: (text, href) => { const a = new Element("a"); a.textContent = text; a.href = href; return a; },
createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"),
createViewIcon: view => { const icon = new Element("svg"); icon.view = view; return icon; },
createStageBadge: () => new Element("span"), createStatusBadge: () => new Element("span"),
opportunityUrl: "https://example.invalid/?puid=", modalId: "modal", closeSrPopover() {} });
h.body = new Element("body"); h.table = new Element("table-surface"); host.append(h.body);
const walk = element => [element, ...element.children.flatMap(walk)];
h.nodes = () => walk(host);
h.byClass = name => h.nodes().find(element => element.className === name);
h.mount = () => h.api.mount(h.body, h.table);
h.activate = () => { h.mount(); h.api.selectView("calendar"); return h.instances[0]; };
return h;
}
test("close dates retain the API day across offsets and reject invalid dates", () => {
const { api } = setup();
for (const date of ["2026-11-15", "2026-11-15T00:00:00Z", "2026-11-15T23:00:00-05:00"]) assert.equal(api.closeDate(date), "2026-11-15");
assert.equal(api.closeDate("2028-02-29"), "2028-02-29");
for (const value of [null, undefined, "", "2026-02-29", "2026-13-01", "2026-04-31", "15/11/2026", 123]) assert.equal(api.closeDate(value), null);
});
test("fiscal week labels use June-May years and Sunday weeks containing June 1", () => {
const { api } = setup();
for (const [day, expected] of [
["2026-05-24", "FY26-Q4-W52"], ["2026-05-31", "FY27-Q1-W1"],
["2026-06-01", "FY27-Q1-W1"], ["2026-06-07", "FY27-Q1-W2"],
["2026-09-06", "FY27-Q2-W15"], ["2026-10-04", "FY27-Q2-W19"],
["2026-11-15", "FY27-Q2-W25"], ["2026-12-06", "FY27-Q3-W28"],
["2027-03-07", "FY27-Q4-W41"], ["2027-05-30", "FY28-Q1-W1"],
["2028-02-27", "FY28-Q3-W40"], ["2028-06-04", "FY29-Q1-W2"]
]) assert.equal(api.fiscalWeekLabel(new Date(`${day}T00:00:00Z`)), expected, day);
});
test("daily count badges follow filters and are cleaned up when cells unmount", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-11-15"), opportunity("C", "2026-11-15")]);
const calendar = h.activate();
const frame = h.document.createElement("div");
const el = { querySelector: selector => { assert.equal(selector, ".fc-daygrid-day-frame"); return frame; } };
calendar.options.dayCellDidMount({ el, date: new Date(2026, 10, 15) });
const badge = frame.children[0];
assert.equal(badge.textContent, "3");
assert.equal(badge.hidden, false);
assert.equal(badge.attributes["aria-label"], "3 oportunidades");
h.api.render([h.state.items[0]], h.state);
assert.equal(badge.textContent, "1");
assert.equal(badge.attributes["aria-label"], "1 oportunidade");
h.api.render([], h.state);
assert.equal(badge.textContent, "0");
assert.equal(badge.attributes["data-empty"], "true");
assert.equal(badge.hidden, true);
h.api.render(h.state.items, h.state);
assert.equal(badge.hidden, false);
assert.equal(badge.textContent, "3");
calendar.options.dayCellWillUnmount({ el });
assert.equal(frame.children.length, 0);
});
test("calendar events use EffectiveDate, all-day semantics and literal untrusted names", () => {
const { api } = setup();
const events = plain(api.eventsFor([opportunity("A", "2026-11-15"), opportunity("B", null)]));
assert.equal(events.length, 1);
assert.equal(events[0].start, "2026-11-15");
assert.equal(events[0].allDay, true);
assert.equal(events[0].title, "<Opportunity A>");
assert.deepEqual(events[0].classNames, ["opportunities-calendar-stage-FORECAST"]);
});
test("initial calendar date selects the next close date, last past date or today", () => {
const { api } = setup();
const now = new Date(2026, 9, 5);
assert.equal(api.initialDate([opportunity("A", "2026-11-15"), opportunity("B", "2026-10-12")], now), "2026-10-12");
assert.equal(api.initialDate([opportunity("A", "2026-09-01"), opportunity("B", "2026-10-01")], now), "2026-10-01");
assert.equal(api.initialDate([opportunity("A", null)], now), "2026-10-05");
});
test("overview is the default; tabs lazily initialize and reuse a monthly/weekly calendar", () => {
const h = setup(); h.mount();
const surface = h.byClass("opportunities-extension-calendar");
assert.equal(h.table.hidden, false); assert.equal(surface.hidden, true); assert.equal(h.loads, 0);
const tabs = h.nodes().filter(element => element.attributes.role === "tab");
assert.deepEqual(tabs.map(tab => tab.children[0].view), ["overview", "calendar"]);
assert.deepEqual(tabs.map(tab => tab.children[1].textContent), ["Vis\u00e3o geral", "Calend\u00e1rio"]);
assert.equal(tabs[0].attributes["aria-selected"], "true");
tabs[1].click();
const instance = h.instances[0];
assert.equal(h.table.hidden, true); assert.equal(surface.hidden, false);
assert.equal(instance.options.initialView, "dayGridMonth");
assert.equal(instance.options.height, "100%");
assert.equal(instance.options.expandRows, true);
assert.equal(instance.options.headerToolbar.right, "dayGridMonth,dayGridWeek");
assert.equal(instance.options.editable, false);
assert.equal(instance.options.dayMaxEvents, 2);
assert.equal(instance.options.moreLinkText(7), "... +7");
assert.equal(instance.options.weekNumbers, true);
tabs[0].click(); tabs[1].click();
assert.equal(h.loads, 1); assert.equal(h.instances.length, 1); assert.equal(instance.resized, true);
tabs[1].listeners.keydown({ key: "Home", preventDefault() {} });
assert.equal(h.table.hidden, false); assert.equal(h.document.activeElement, tabs[0]);
});
test("responsive event limit reserves room for every week and the overflow control", () => {
const { api } = setup();
assert.equal(api.visibleEventLimit(240, 6, 21), 0);
assert.equal(api.visibleEventLimit(500, 5, 21), 1);
assert.equal(api.visibleEventLimit(800, 5, 21), 2);
assert.equal(api.visibleEventLimit(500, 1, 21), 2);
assert.equal(api.visibleEventLimit(0, 6, 21), 0);
});
test("calendar follows filtered data; only a new Reference request changes its date", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-12-12")]);
const calendar = h.activate();
h.filtered = [h.state.items[1]]; h.api.render(h.filtered, h.state);
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["B"]);
assert.equal(calendar.dates.length, 0);
h.version++; h.state.items = [opportunity("C", "2027-02-20")]; h.filtered = h.state.items;
h.api.render(h.filtered, h.state);
assert.deepEqual(calendar.dates, ["2027-02-20"]);
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["C"]);
h.filtered = []; h.api.render(h.filtered, h.state);
assert.equal(calendar.events.length, 0);
assert.equal(h.byClass("opportunities-calendar-message").textContent, "No opportunities found.");
});
test("undated opportunities remain visible and open read-only details", () => {
const h = setup([opportunity("A", null)]); const calendar = h.activate();
assert.equal(calendar.events.length, 0);
const undated = h.byClass("opportunities-calendar-undated");
assert.equal(undated.hidden, false); undated.children[1].click();
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Owner", "Close Date", "Amount"]);
assert.equal(h.nodes().find(node => node.tag === "a").href, "https://example.invalid/?puid=A");
assert.ok(h.nodes().some(node => node.tag === "dd" && node.textContent === "N/D"));
assert.equal(h.api.closeDetails(), true);
assert.equal(h.document.activeElement, undated.children[1]);
});
test("event details close on changed data, loading/error hide stale calendar content", () => {
const h = setup(); const calendar = h.activate();
calendar.options.eventClick({ event: { extendedProps: { item: h.state.items[0] } }, el: h.table, jsEvent: { preventDefault() {} } });
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
h.api.render([], h.state);
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
for (const status of ["loading", "error", "empty"]) {
h.api.render([], { status, items: [], message: "State message" });
assert.equal(h.byClass("opportunities-calendar-grid").hidden, true);
assert.equal(h.byClass("opportunities-calendar-undated").hidden, true);
assert.equal(h.byClass("opportunities-calendar-message").textContent, "State message");
}
});
test("overflow uses our dialog instead of the FullCalendar offsetParent-dependent popover", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-11-15"), opportunity("C", "2026-11-15")]);
const calendar = h.activate();
const trigger = h.document.createElement("a");
const allSegs = calendar.events.map(event => ({ event }));
const open = () => calendar.options.moreLinkClick({ date: new Date("2026-11-15T00:00:00Z"), allSegs,
jsEvent: { currentTarget: trigger, preventDefault() {} } });
assert.equal(open(), true, "truthy non-string result suppresses vendor popover");
const list = h.byClass("opportunities-calendar-day-list");
assert.equal(list.children.length, 3);
assert.ok(list.children.every(button => button.children.length === 2 && button.children[1].textContent.includes(" | FORECAST | $100")));
list.children[2].click();
assert.ok(h.nodes().some(node => node.tag === "h2" && node.textContent === "<Opportunity C>"));
h.api.closeDetails();
assert.equal(h.document.activeElement, trigger);
open(); h.api.selectView("overview");
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
h.api.selectView("calendar"); open();
const dialog = h.nodes().find(node => node.attributes.role === "dialog");
dialog.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
});
test("calendar events show their stage and handle missing stage values", () => {
const h = setup();
const calendar = h.activate();
for (const [value, label] of [["FORECAST", "FORECAST"], ["UPSIDE", "UPSIDE"], [null, "N/D"]]) {
const item = { ...h.state.items[0], ForecastGroup_c: value };
const { domNodes } = calendar.options.eventContent({ event: { title: item.Name, extendedProps: { item } } });
assert.equal(domNodes[0].children.length, 2);
assert.equal(domNodes[0].children[0].textContent, item.Name);
assert.equal(domNodes[0].children[1].textContent, `A | ${label} | $100`);
assert.equal(domNodes[0].children[1].title, `A | ${label} | $100`);
}
});

View File

@@ -84,7 +84,7 @@ test("ramp months preserve zero and flag missing values beside the product count
assert.equal(badge(), undefined);
for (const [values, expected] of [
[[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
[[0], "0"], [[0, "3", null, " 6 ", 3], "0,3,N/D,6,3"]
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/D | 6 | 3"]
]) {
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
@@ -94,7 +94,7 @@ test("ramp months preserve zero and flag missing values beside the product count
else {
assert.equal(badge().textContent, expected);
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D")));
assert.equal(badge().title, `Ramp Months: ${expected.split(",").join(", ")}`);
assert.equal(badge().title, `Ramp Months: ${expected.split(" | ").join(", ")}`);
assert.equal(badge().attributes["aria-label"], badge().title);
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
}
@@ -132,6 +132,32 @@ test("product labels support multiple digits without changing their tooltip", ()
assert.equal(count.attributes["aria-label"], "12 products for opportunity TEST-1");
});
test("opportunity projection alert is last and aggregates only divergent products", () => {
const h = setup();
const alert = () => findClass(h.cell("name"), "opportunities-extension-opportunity-projection-alert");
const product = amount => ({ RampMonths_c: 0, workload: { adjustedACR: 1200, consumptionStartDate: "2026-10-01",
forecast: [{ year: 2026, month: 10, adjustedConsumptionAmount: amount }] } });
assert.equal(alert(), undefined);
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [product(90), product(100), product(110), {}] });
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(alert().title, "Consumption forecast differs in 2 products. View products.");
const actions = findClass(h.cell("name"), "opportunities-extension-cell-actions");
assert.equal(actions.children.at(-1), alert());
assert.equal(alert().tag, "button");
assert.equal(alert().attributes["aria-expanded"], "false");
alert().click();
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row");
assert.equal(alert().attributes["aria-expanded"], "true");
assert.equal(walk(h.rows()[1]).filter(node => node.attributes["aria-haspopup"] === "dialog").length, 2);
assert.deepEqual(h.summaries, ["TEST-1"]);
alert().click();
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row", "clicking again never collapses the list");
assert.deepEqual(h.summaries, ["TEST-1"], "already expanded list is not reloaded");
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [product(100), {}] });
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(alert(), undefined);
});
test("opportunity badges share height, typography and alignment including the Action plus button", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
const shared = css.match(/\.opportunities-extension-cell-actions > \.opportunities-extension-products-count,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-products-ramp,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-crm-sr-badge\s*\{([^}]+)\}/);
@@ -243,7 +269,8 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "")
.replace(/[^{}]*\.opportunities-extension-title-row[^{}]*\{[^}]*\}/g, "");
.replace(/[^{}]*\.opportunities-extension-title-row[^{}]*\{[^}]*\}/g, "")
.replace(/[^{}]*\.opportunities-extension-sr-confirm-dialog\s*\{[^}]*\}/g, "");
assert.doesNotMatch(tableRules, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
assert.match(css, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
@@ -253,6 +280,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.ok(remaining > 0);
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", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.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", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css", "src/features/products/ramp-projection.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});

View File

@@ -94,8 +94,8 @@ function setup() {
app.opportunityWorkloadActions.loadProducts = (products, refresh, options) => { if (!options?.cacheOnly) calls.push(products.items[0]?.RevnId || "empty products"); };
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true });
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
function start(token = "token") {
app.productsContentJobs.requestOpptyProductsInBackground([{ OptyNumber: "A" }], token);
function start(token = "token", numbers = ["A"]) {
app.productsContentJobs.requestOpptyProductsInBackground(numbers.map(OptyNumber => ({ OptyNumber })), token);
return app.productsContentState.currentProductsRequestId;
}
function progress(requestId, data) {
@@ -147,19 +147,33 @@ test("partial failures use successful products only; closed, failed and empty cy
assert.equal(h.calls.includes("closed"), false);
});
test("cached products restore activities without network; mixed cycles fetch only fresh products", async () => {
test("cached and mixed product cycles both allow activity cache misses to be fetched", async () => {
const h = setup();
const cached = h.start();
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
h.progress(cached, { state: "complete" });
await flush();
assert.deepEqual(h.calls, []);
const mixed = h.start();
assert.deepEqual(h.calls, ["SR token", "cached"]);
h.calls.length = 0;
const mixed = h.start("token", ["A", "B"]);
h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
h.progress(mixed, { state: "complete" });
await flush();
assert.deepEqual(h.calls, ["SR token", "fresh"]);
assert.deepEqual(h.calls, ["SR token", "cached", "fresh"]);
});
test("activity scope includes hidden reference opportunities and excludes entries from other references", async () => {
const h = setup();
h.app.opportunitiesFilterState.tableSearch = "hide everything";
const id = h.start("token", ["A", "B"]);
h.progress(id, { optyNumber: "A", items: [{ RevnId: "1" }, { RevnId: "2" }] });
h.progress(id, { optyNumber: "B", items: [{ RevnId: "3" }] });
h.progress(id, { optyNumber: "OTHER_REFERENCE", items: [{ RevnId: "outside" }] });
h.progress(id, { state: "complete" });
await flush();
assert.deepEqual(h.calls, ["SR token", "1", "3"]);
assert.equal(h.calls.includes("outside"), false);
});
test("reopening an existing modal does not reload products or reset/query activities", async () => {

View File

@@ -0,0 +1,209 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const model = loadApplication("content").rampProjection;
const product = (workload = {}, overrides = {}) => ({ RampMonths_c: 2, RevnAmount: 1,
ConsumptionStartDate_c: "2000-01-01", workload: { adjustedACR: 50000,
consumptionStartDate: "2026-11-01T00:00:00", forecast: [{ month: 11, year: 2026, adjustedConsumptionAmount: 1515.15 }],
...workload }, ...overrides });
test("ramp formula agrees with all twelve workbook ramps and preserves the annual total", () => {
// rampas!E6:E17 and the stabilized values in P6:P17, annual value F2 = 50000.
const first = [4166.666666666667, 2173.913043478261, 1515.151515151515, 1190.4761904761904, 1000,
877.1929824561404, 793.6507936507936, 735.2941176470588, 694.4444444444445, 666.6666666666667,
649.3506493506494, 641.025641025641];
const stable = [4166.666666666667, 4347.826086956522, 4545.454545454545, 4761.9047619047615,
5000, 5263.1578947368425, 5555.555555555556, 5882.35294117647, 6250, 6666.666666666667,
7142.857142857143, 7692.307692307692];
for (let ramp = 0; ramp <= 11; ramp++) {
assert.ok(Math.abs(model.monthlyAmount(50000, ramp, 1) - first[ramp]) < 1e-9);
assert.ok(Math.abs(model.monthlyAmount(50000, ramp, 12) - stable[ramp]) < 1e-9);
const months = Array.from({ length: 12 }, (_, i) => model.monthlyAmount(50000, ramp, i + 1));
assert.ok(Math.abs(months.reduce((sum, value) => sum + value, 0) - 50000) < 1e-9);
assert.equal(model.monthlyAmount(50000, ramp, 13), months[11], "no annual restart");
assert.equal(model.monthlyAmount(50000, ramp, 0), 0);
}
});
test("comparison uses adjustedACR, product RampMonths_c, calendar months and cent precision", () => {
const result = model.compare(product({ rampMonths: 0, actualRampMonths: 11, forecast: [
{ month: 1, year: 2027, adjustedConsumptionAmount: 4500 },
{ month: 11, year: 2026, adjustedConsumptionAmount: "1515.151515" },
{ month: 12, year: 2026, adjustedConsumptionAmount: 3200 },
{ month: 10, year: 2026, adjustedConsumptionAmount: 0 }
] }));
assert.equal(result.status, "divergent");
assert.equal(result.divergentCount, 2);
assert.deepEqual(Array.from(result.rows, row => [row.key, row.calculated, row.difference]), [
["2026-10", 0, 0], ["2026-11", 1515.15, 0], ["2026-12", 3030.30, 169.70], ["2027-01", 4545.45, -45.45]
]);
assert.equal(model.compare(product()).status, "matched");
});
test("first month is prorated inclusively without timezone shifts, including leap years", () => {
for (const [start, month, year, expected, days, total] of [
["2026-11-25T00:00:00-0300", 11, 2026, 303.03, 6, 30],
["2026-11-30T00:00:00Z", 11, 2026, 50.51, 1, 30],
["2028-02-29T00:00:00+1400", 2, 2028, 52.25, 1, 29],
["2027-02-28", 2, 2027, 54.11, 1, 28],
["2026-12-01", 12, 2026, 1515.15, 31, 31]
]) {
const result = model.compare(product({ consumptionStartDate: start,
forecast: [{ month, year, adjustedConsumptionAmount: expected }] }));
assert.equal(result.rows[0].calculated, expected, start);
assert.equal(result.status, "matched", start);
assert.equal(result.firstMonthDays, days);
assert.equal(result.firstMonthTotalDays, total);
}
});
test("missing or invalid inputs never create false mismatches or silently default to zero", () => {
for (const value of [null, "", "garbage", -1, Infinity]) assert.equal(model.compare(product({ adjustedACR: value })).status, "unavailable");
for (const value of [null, "", "garbage", -1, 12, 1.5]) assert.equal(model.compare(product({}, { RampMonths_c: value })).status, "unavailable");
for (const value of [null, "", "2026-02-30", "2026-13-01"]) assert.equal(model.compare(product({ consumptionStartDate: value })).status, "unavailable");
assert.equal(model.compare({}).status, "unavailable");
for (const forecast of [[], null, [{ year: 2026, month: 0 }], [{ year: 2026, month: 11 }, { year: 2026, month: 11 }]]) {
assert.equal(model.compare(product({ forecast })).status, "unavailable");
}
assert.equal(model.compare(product({ adjustedACR: 0, forecast: [{ year: 2026, month: 11, adjustedConsumptionAmount: 0 }] }, { RampMonths_c: "0" })).status, "matched");
const missing = model.compare(product({ forecast: [{ year: 2026, month: 11, projectedConsumptionAmount: 500 }] }));
assert.equal(missing.status, "unavailable");
assert.equal(missing.rows[0].actual, null);
assert.equal(missing.rows[0].difference, null);
});
test("calculation is read-only and keeps missing monthly values visible beside real differences", () => {
const input = product({ forecast: [{ year: 2026, month: 11 }, { year: 2026, month: 12, adjustedConsumptionAmount: 0 }] });
const before = JSON.stringify(input);
const result = model.compare(input);
assert.equal(JSON.stringify(input), before);
assert.equal(result.status, "divergent");
assert.equal(result.divergentCount, 1);
assert.equal(result.missingCount, 1);
assert.ok(result.reason);
});
function dom() {
const document = { activeElement: null };
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.dataset = {}; this.listeners = {}; this.classList = { toggle() {} }; this.inert = false; this.isConnected = true; }
set textContent(value) { this.text = String(value); this.children = []; }
get textContent() { return (this.text || "") + this.children.map(child => child.textContent).join(""); }
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
setAttribute(name, value) { this.attributes[name] = value; }
addEventListener(name, fn) { this.listeners[name] = fn; }
focus() { document.activeElement = this; }
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
click() { this.listeners.click?.({ preventDefault() {}, stopPropagation() {} }); }
}
const host = new Element("main"), content = new Element("div");
host.append(content);
document.createElement = tag => new Element(tag);
document.createElementNS = (_, tag) => new Element(tag);
document.getElementById = () => host;
return { document, host, content };
}
const walk = root => [root, ...root.children.flatMap(walk)];
test("Update estimates prevents duplicate clicks, reports errors, retries and closes on success", async () => {
const { document, host } = dom();
const app = loadApplication("content", { document, Intl, Date });
const input = product({ forecast: [{ year: 2026, month: 11, adjustedConsumptionAmount: 0 }] });
const notifications = [];
app.opportunitiesLinks.showCopyConfirmation = (...args) => notifications.push(args);
let reject, calls = 0;
app.workloadForecastUpdate.save = () => { calls++; return new Promise((_, fail) => { reject = fail; }); };
app.rampProjectionView.open(input, { OptyNumber: "TEST" });
const modal = host.children[1];
const button = walk(modal).find(node => node.textContent === "Update estimates");
const event = { preventDefault() {}, stopPropagation() {} };
button.listeners.click(event);
assert.equal(calls, 0, "opening confirmation must not save");
const confirmation = () => walk(modal).find(node => node.attributes.role === "alertdialog");
assert.ok(confirmation().textContent.includes("The estimates will be updated directly in the SPA"));
const cancel = walk(confirmation()).find(node => node.textContent === "Cancel");
assert.equal(document.activeElement, cancel);
cancel.click();
assert.equal(confirmation(), undefined);
assert.equal(document.activeElement, button);
assert.equal(calls, 0, "cancelling must not save");
button.click();
const pending = walk(confirmation()).find(node => node.textContent === "Confirm update").listeners.click(event);
button.click();
assert.equal(confirmation(), undefined);
assert.equal(calls, 1);
assert.equal(button.disabled, true);
assert.equal(app.rampProjectionView.close(), true);
assert.equal(host.children.length, 2, "cannot dismiss while saving");
reject(new Error("Forbidden"));
await pending;
assert.equal(button.disabled, false);
assert.deepEqual(notifications, [["Forbidden", true]]);
app.workloadForecastUpdate.save = async () => { calls++; };
button.click();
await walk(confirmation()).find(node => node.textContent === "Confirm update").listeners.click(event);
assert.equal(host.children.length, 1);
assert.deepEqual(notifications[1], ["Workload estimates updated successfully."]);
});
test("single-product opportunity alert opens the comparison directly with opportunity context", () => {
const { document, host, content } = dom();
const app = loadApplication("content", { document, Intl, Date });
const opportunity = { OptyNumber: "ONLY", CustomerAccount: { PartyUniqueName: "Single customer" } };
const input = product({ forecast: [{ year: 2026, month: 11, adjustedConsumptionAmount: 0 }] });
let expansions = 0;
app.productsDetails.expandProducts = () => { expansions++; };
const alert = app.rampProjectionView.createOpportunityIndicator({ items: [input] }, opportunity, false);
content.append(alert);
assert.equal(alert.attributes["aria-haspopup"], "dialog");
assert.equal(alert.attributes["aria-expanded"], undefined);
assert.match(alert.title, /View comparison/);
alert.click();
assert.equal(expansions, 0);
assert.equal(host.children[1].attributes.role, "dialog");
assert.ok(host.children[1].textContent.includes("ONLY"));
assert.ok(host.children[1].textContent.includes("Single customer"));
app.rampProjectionView.close();
assert.equal(document.activeElement, alert);
const multiple = app.rampProjectionView.createOpportunityIndicator({ items: [input, product()] }, opportunity, false);
assert.equal(multiple.attributes["aria-haspopup"], undefined);
multiple.click();
assert.equal(expansions, 1, "two products still expand even when only one is divergent");
assert.equal(host.children.length, 1);
});
test("product column opens accessible read-only comparison and restores focus/background on close", () => {
const { document, host, content } = dom();
const app = loadApplication("content", { document, Intl, Date });
const input = product({ consumptionStartDate: "2026-11-25", forecast: [{ year: 2026, month: 11, adjustedConsumptionAmount: 1000 }] },
{ RevnId: 1, ProdGroupName: "<b>Product</b>", RevnAmountCurcyCode: "USD" });
app.productsContentState.opptyProductsByNumber.set("TEST", { items: [input] });
const row = app.productsDetails.createProductsDetailRow({ OptyNumber: "TEST", CustomerAccount: { PartyUniqueName: "Example" } }, 10);
const alert = walk(row).find(node => node.attributes["aria-haspopup"] === "dialog");
assert.ok(walk(row).some(node => node.tag === "th" && node.textContent === "Projection"));
assert.ok(alert);
assert.equal(alert.children[0].tag, "svg");
content.append(row);
alert.click();
const modal = host.children[1];
assert.equal(modal.attributes.role, "dialog");
assert.equal(content.inert, true);
const elements = walk(modal);
for (const text of ["Forecast", "Calculated", "Difference", "11/2026 (Prorated)", "$303.03", "$696.97"]) {
assert.ok(elements.some(node => node.textContent === text), text);
}
assert.ok(elements.some(node => node.textContent === "<b>Product</b>"));
assert.equal(elements.some(node => node.tag === "b"), false, "names use safe text nodes");
const closeButton = elements.find(node => node.title === "Close consumption projection");
assert.equal(document.activeElement, closeButton);
let prevented = false;
modal.listeners.keydown({ key: "Tab", shiftKey: true, preventDefault() { prevented = true; } });
assert.equal(prevented, true);
assert.equal(document.activeElement.textContent, "Update estimates");
modal.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
assert.equal(host.children.length, 1);
assert.equal(content.inert, false);
assert.equal(document.activeElement, alert);
assert.equal(app.rampProjectionView.createIndicator(product(), {}).textContent, "OK");
assert.equal(app.rampProjectionView.createIndicator({}, {}).textContent, "-");
});

119
tests/spaPage.test.cjs Normal file
View File

@@ -0,0 +1,119 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const vm = require("node:vm");
const { composeScript } = require("../scripts/compose.cjs");
const script = new vm.Script(composeScript("spa-page"));
const inspect = "opportunitiesExtension.inspectSpaPage";
const read = "opportunitiesExtension.readSpaIdentity";
const validIdentity = { user: { id: "test@example.invalid", name: "Test User", roles: [] }, token: "synthetic-token" };
function harness({ family = "chrome", origin = "https://spa.oracle.com", subframe = false, fetchResponse } = {}) {
let listener;
const requests = [];
const timers = new Map();
const dom = { pathname: "/oalcrm/", title: "SPA", body: "Sales Planning and Analytics", readyState: "complete", controls: true, loginSelector: "" };
const window = {};
window.top = subframe ? {} : window;
const document = {
get title() { return dom.title; },
get body() { return { innerText: dom.body }; },
get readyState() { return dom.readyState; },
querySelector: selector => selector.startsWith("a[href]") ? dom.controls : Boolean(dom.loginSelector && selector.includes(dom.loginSelector))
};
script.runInNewContext({
window, document, location: { origin, get pathname() { return dom.pathname; } }, performance: { timeOrigin: 123456 },
[family]: { runtime: { id: "test-extension", onMessage: { addListener: callback => { listener = callback; } } } },
AbortController,
setTimeout: callback => { const id = {}; timers.set(id, callback); return id; },
clearTimeout: id => timers.delete(id),
fetch: async (url, options) => {
requests.push({ url, options });
return fetchResponse ? fetchResponse(options) : new Response(JSON.stringify(validIdentity), { headers: { "Content-Type": "application/json", "X-Request-Id": "synthetic" } });
}
});
const send = (type, documentId = "123456:/oalcrm/") => new Promise(resolve => listener({ type, documentId }, { id: "test-extension" }, resolve));
return { dom, send, requests, timers, get listener() { return listener; } };
}
test("SPA inspector is restricted to the top-level SPA origin and trusted extension messages", () => {
assert.equal(harness({ origin: "https://other.example.invalid" }).listener, undefined);
assert.equal(harness({ subframe: true }).listener, undefined);
const h = harness();
assert.equal(h.listener({ type: inspect }, { id: "other-extension" }, () => assert.fail("Must not reply")), false);
assert.equal(h.listener({ type: "unknown" }, { id: "test-extension" }, () => assert.fail("Must not reply")), false);
assert.equal(h.requests.length, 0, "installing the inspector never starts an API request");
});
test("error, SAML/login, blank and incomplete pages never authorize a fetch", async () => {
const cases = [
[{ body: "401 Authorization Required" }, "error"],
[{ title: "403 Forbidden" }, "error"],
[{ body: "Internal Server Error" }, "error"],
[{ pathname: "/cloudgate/v1/oauth2/callback" }, "login"],
[{ title: "Oracle Sign In" }, "login"],
[{ loginSelector: 'input[name="SAMLResponse"]' }, "login"],
[{ loginSelector: 'input[name="SAMLRequest"]' }, "login"],
[{ loginSelector: 'input[type="password"]' }, "login"],
[{ loginSelector: 'meta[http-equiv="refresh" i]' }, "login"],
[{ readyState: "interactive" }, "loading"],
[{ readyState: "loading" }, "loading"],
[{ title: "", body: "" }, "loading"],
[{ controls: false }, "loading"]
];
for (const [dom, expected] of cases) {
const h = harness();
Object.assign(h.dom, dom);
assert.equal((await h.send(inspect)).status, expected, JSON.stringify(dom));
assert.match((await h.send(read)).error, /not ready/);
assert.equal(h.requests.length, 0);
}
});
for (const family of ["chrome", "browser"]) {
test(`${family}: a loaded application reads identity once per document in its own authenticated context`, async () => {
const h = harness({ family });
const page = await h.send(inspect);
assert.equal(page.status, "ready");
const [first, second] = await Promise.all([h.send(read, page.documentId), h.send(read, page.documentId)]);
assert.deepEqual(first, second);
assert.deepEqual(JSON.parse(first.body), validIdentity);
assert.equal(first.headers["x-request-id"], "synthetic");
assert.equal((await h.send(read)).body, first.body);
assert.equal(h.requests.length, 1);
assert.equal(h.requests[0].url, "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current");
assert.equal(h.requests[0].options.method, "GET");
assert.equal(h.requests[0].options.credentials, "include");
assert.equal(h.requests[0].options.cache, "no-store");
assert.equal(h.timers.size, 0);
assert.match((await h.send(read, "old-document")).error, /navigated/);
h.dom.pathname = "/login";
assert.match((await h.send(read)).error, /not ready/);
assert.equal(h.requests.length, 1);
});
}
test("network or HTML failures are not retried in the same SPA document", async () => {
for (const fetchResponse of [
() => { throw new Error("Network failure"); },
() => new Response('<html><form><input name="SAMLResponse"></form></html>', { headers: { "Content-Type": "text/html" } })
]) {
const h = harness({ fetchResponse });
const first = await h.send(read);
for (let i = 0; i < 20; i++) assert.deepEqual(await h.send(read), first);
assert.equal(h.requests.length, 1);
assert.equal(h.timers.size, 0);
}
});
test("a stalled identity fetch is aborted and never leaves a retry timer", async () => {
const h = harness({ fetchResponse: options => new Promise((resolve, reject) => {
options.signal.addEventListener("abort", () => reject(new Error("Timed out")));
}) });
const pending = h.send(read);
assert.equal(h.timers.size, 1);
[...h.timers.values()][0]();
assert.equal((await pending).error, "Timed out");
assert.equal(h.timers.size, 0);
await h.send(read);
assert.equal(h.requests.length, 1);
});

View File

@@ -0,0 +1,87 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const currentUser = "opportunitiesExtension.requestCurrentUser";
const consumer = "opportunitiesExtension.requestConsumerTokenService";
const identity = { user: { id: "test@example.invalid", name: "Test User", roles: [] }, token: "synthetic-token" };
const tokens = [
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } }
];
const html = () => new Response("<html>Sign in</html>");
function navigation(app, overrides = {}) {
const observer = app.responseHeaderListeners.find(item => item.filter.types.includes("main_frame"));
assert.ok(observer);
observer.listener({ tabId: 91, type: "main_frame", statusCode: 401,
url: "https://spa.oracle.com/cloudgate/v1/oauth2/callback?code=do-not-replay", ...overrides });
}
for (const family of ["chrome", "firefox"]) {
for (const type of [currentUser, consumer]) {
test(`${family}: ${type} restarts a rejected callback at SPA home in the same inactive tab`, async () => {
let calls = 0;
const app = loadBackground({ family, fetch: async () => {
calls++;
if (calls === 1) return html();
if (calls === 2) { navigation(app); return html(); }
assert.deepEqual(app.updatedTabs, [{ id: 91, details: { url: "https://spa.oracle.com/", active: false } }]);
return response(type === currentUser ? identity : tokens);
} });
const result = await app.dispatch({ type });
assert.equal(result.ok, true);
assert.equal(app.tabs.length, 1);
assert.deepEqual(app.removedTabs, [91]);
assert.equal(result.debugRequests[1].metadata.sessionBootstrapReloadState, "restarted-at-spa-home");
assert.match(result.debugRequests[1].metadata.sessionBootstrapReloadReason, /401/);
assert.doesNotMatch(JSON.stringify(result.debugRequests), /do-not-replay/);
assert.equal(app.responseHeaderListeners.filter(item => item.filter.types.includes("main_frame")).length, 0);
});
}
test(`${family}: failed navigation is recorded and retried after the cooldown without opening another tab`, async () => {
let calls = 0;
const app = loadBackground({ family, updateTabError: true, fetch: async () => {
calls++;
if (calls === 2) navigation(app, { statusCode: 403 });
return calls < 5 ? html() : response(identity);
} });
const result = await app.dispatch({ type: currentUser });
assert.equal(result.ok, false);
assert.equal(calls, 2, "failed navigation must never release another identity probe");
assert.equal(app.updatedTabs.length, 3, "at most three restart attempts per session wait");
assert.equal(app.tabs.length, 1);
assert.match(result.debugRequests[1].metadata.sessionBootstrapReloadState, /restart-failed/);
assert.equal(result.debugRequests[1].metadata.sessionBootstrapReloadCount, "3");
assert.deepEqual(app.removedTabs, [91]);
});
}
test("only errors in the owned SPA document trigger navigation, never other tabs, frames or hosts", async () => {
let calls = 0;
const app = loadBackground({ fetch: async () => {
if (++calls === 1) return html();
if (calls === 2) {
navigation(app, { tabId: 7 });
navigation(app, { type: "sub_frame" });
navigation(app, { type: "xmlhttprequest" });
navigation(app, { url: "https://other.example.invalid/callback" });
navigation(app, { statusCode: 302 });
assert.equal(app.updatedTabs.length, 0);
}
return response(identity);
} });
assert.equal((await app.dispatch({ type: currentUser })).ok, true);
});
test("a successful SPA navigation clears an earlier failure before it can be restarted", async () => {
let calls = 0;
const app = loadBackground({ fetch: async () => {
if (++calls === 1) return html();
if (calls === 2) {
navigation(app);
navigation(app, { url: "https://spa.oracle.com/", statusCode: 200 });
assert.equal(app.updatedTabs.length, 0);
}
return response(identity);
} });
assert.equal((await app.dispatch({ type: currentUser })).ok, true);
});

View File

@@ -0,0 +1,181 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const type = "opportunitiesExtension.requestCurrentUser";
const identity = { user: { id: "test@example.invalid", name: "Test User", roles: [] }, token: "synthetic-token" };
const flush = () => new Promise(resolve => setImmediate(resolve));
function harness({ validAfter = 0 } = {}) {
let now = Date.UTC(2026, 9, 21);
let sequence = 0;
let backgroundCalls = 0;
let tabCalls = 0;
let listener;
let page = { status: "loading", documentId: "first-document" };
const timers = new Map();
const tabs = [];
const updates = [];
const removed = [];
function event() {
const listeners = new Set();
return { listeners, addListener: callback => listeners.add(callback), removeListener: callback => listeners.delete(callback),
emit: value => [...listeners].forEach(callback => callback(value)) };
}
const headers = event();
const cookies = event();
class ClockDate extends Date {
constructor(...args) { super(...(args.length ? args : [now])); }
static now() { return now; }
}
const app = loadApplication("background", {
chrome: { runtime: { onMessage: { addListener: callback => { listener = callback; } } },
webRequest: { onHeadersReceived: headers }, cookies: { onChanged: cookies } }, URL,
Date: ClockDate,
setTimeout: function(callback, delay) {
"use strict";
assert.equal(this, undefined);
const id = ++sequence;
timers.set(id, { callback, at: now + delay });
return id;
},
clearTimeout: id => timers.delete(id),
fetch: async () => { backgroundCalls++; return new Response("<html>SSO</html>"); }
});
app.platformBackgroundApi.tabsCreate = async details => { tabs.push(details); return { id: 91 }; };
app.platformBackgroundApi.tabsUpdate = async (id, details) => { updates.push({ id, details }); return { id }; };
app.platformBackgroundApi.tabsRemove = async id => { removed.push(id); };
app.platformBackgroundApi.tabsSendMessage = async (id, message, options) => {
assert.equal(id, 91);
assert.equal(options.frameId, 0);
if (message.type === "opportunitiesExtension.inspectSpaPage") return page;
assert.equal(message.type, "opportunitiesExtension.readSpaIdentity");
assert.equal(message.documentId, page.documentId);
assert.equal(page.status, "ready");
const valid = ++tabCalls > validAfter;
return { status: 200, contentType: valid ? "application/json" : "text/html", body: valid ? JSON.stringify(identity) : "<html>SSO</html>" };
};
app.start();
function dispatch() {
return new Promise(resolve => listener({ type }, { tab: { id: 7, windowId: 3, cookieStoreId: "store-a" } }, resolve));
}
async function advance(ms) {
const until = now + ms;
for (;;) {
const next = [...timers].filter(([, timer]) => timer.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
if (!next) break;
now = next[1].at;
timers.delete(next[0]);
next[1].callback();
await flush();
}
now = until;
await flush();
}
function navigation(overrides = {}) {
headers.emit({ tabId: 91, type: "main_frame", statusCode: 200, url: "https://spa.oracle.com/", ...overrides });
}
function cookie() {
cookies.emit({ removed: false, cookie: { name: "synthetic-session", value: "never-log-this-cookie", domain: "spa.oracle.com",
storeId: "store-a", secure: true, httpOnly: true } });
}
return { dispatch, advance, navigation, cookie, tabs, updates, removed, timers, cookies, headers,
setPage: (status, documentId = "first-document") => { page = { status, documentId }; },
get backgroundCalls() { return backgroundCalls; }, get tabCalls() { return tabCalls; } };
}
test("HTTP 200, cookies and sixty seconds do not release identity GETs on a login page", async () => {
const h = harness();
const first = h.dispatch();
const second = h.dispatch();
await flush();
h.setPage("login");
h.navigation();
h.cookie();
await h.advance(60000);
assert.equal(h.backgroundCalls, 1);
assert.equal(h.tabCalls, 0);
h.setPage("ready");
await h.advance(1500);
const [result, shared] = await Promise.all([first, second]);
assert.equal(result.ok, true);
assert.deepEqual(result, shared);
assert.equal(h.backgroundCalls, 1);
assert.equal(h.tabCalls, 1, "identity is read in the verified SPA tab, not in the background");
assert.equal(h.tabs.length, 1);
assert.deepEqual(h.removed, [91]);
assert.equal(h.timers.size, 0);
assert.equal(h.cookies.listeners.size, 0);
assert.equal(h.headers.listeners.size, 1, "only the unrelated SR observer remains");
assert.equal(result.debugRequests[0].metadata.sessionWaitReason, "spa-document-verified");
assert.equal(result.debugRequests[1].metadata.executionContext, "spa-tab");
assert.doesNotMatch(JSON.stringify(result), /never-log-this-cookie/);
});
test("callback 401 restarts only the owned SPA home; another HTTP 200 error page still blocks GETs", async () => {
const h = harness();
const pending = h.dispatch();
await flush();
h.setPage("error");
h.navigation({ statusCode: 401, url: "https://spa.oracle.com/cloudgate/v1/oauth2/callback?code=never-replay" });
await h.advance(1000);
assert.equal(h.updates.length, 1);
assert.equal(h.updates[0].details.url, "https://spa.oracle.com/");
assert.equal(h.updates[0].details.active, false);
h.navigation();
await h.advance(10000);
assert.equal(h.tabCalls, 0);
assert.equal(h.updates.length, 1, "do not repeatedly restart a document already being navigated away from");
h.setPage("ready", "new-document");
await h.advance(1500);
assert.equal((await pending).ok, true);
assert.equal(h.tabCalls, 1);
});
test("timeout fails closed with no fallback API request", async () => {
const h = harness();
const pending = h.dispatch();
await flush();
h.navigation();
h.cookie();
await h.advance(120000);
const result = await pending;
assert.equal(result.ok, false);
assert.match(result.error, /did not finish authentication/);
assert.equal(result.debugRequests.length, 1);
assert.equal(h.backgroundCalls, 1);
assert.equal(h.tabCalls, 0);
assert.deepEqual(h.removed, [91]);
assert.equal(h.timers.size, 0);
});
test("invalid identity requires a new verified document; the old ready document is never probed twice", async () => {
const h = harness({ validAfter: 1 });
h.setPage("ready");
const pending = h.dispatch();
await flush();
await h.advance(500);
assert.equal(h.tabCalls, 1);
assert.equal(h.updates.length, 1);
await h.advance(30000);
assert.equal(h.tabCalls, 1);
assert.equal(h.updates.length, 1);
h.setPage("ready", "reloaded-document");
await h.advance(1500);
assert.equal((await pending).ok, true);
assert.equal(h.tabCalls, 2);
assert.equal(h.backgroundCalls, 1);
});
test("a redirect during stable-document confirmation does not trigger a GET", async () => {
const h = harness();
h.setPage("ready");
const pending = h.dispatch();
await flush();
h.setPage("login", "redirect-document");
await h.advance(2000);
assert.equal(h.tabCalls, 0);
h.setPage("ready", "final-document");
await h.advance(2000);
assert.equal((await pending).ok, true);
assert.equal(h.tabCalls, 1);
});

Some files were not shown because too many files have changed in this diff Show More