Compare commits
15 Commits
4d3f1f9760
...
v1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
19edee4e11
|
|||
|
ba61f91c53
|
|||
|
5ef4c22c59
|
|||
|
60daabfe91
|
|||
|
71d550dfb1
|
|||
|
cc025e488d
|
|||
|
9eef03e5f4
|
|||
|
d392e5ddd7
|
|||
|
2ae057cc90
|
|||
|
8b4d88b226
|
|||
|
c9de750261
|
|||
|
7a7143b6e7
|
|||
|
e9c295be91
|
|||
|
f512ef9928
|
|||
|
3fd89a307c
|
89
README.md
89
README.md
@@ -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
|
## 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,
|
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
|
||||||
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
|
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.
|
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;
|
Identity must contain a valid user (id, name, roles) and token before dependent
|
||||||
other opportunity features continue. Reload the page to refresh identity/roles.
|
requests start. Failed identity validation keeps those requests waiting and retries
|
||||||
The unused identity token is discarded and redacted from debug responses.
|
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
|
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,
|
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,
|
uses safe text nodes only after the existing sensitive-value masking. Compact JSON,
|
||||||
supported protection prefixes and JSON documents wrapped in strings are normalized
|
supported protection prefixes and JSON documents wrapped in strings are normalized
|
||||||
and indented, including while sensitive values are visible. Large JSON responses
|
and indented, including while sensitive values are visible. Large JSON responses
|
||||||
are captured as bounded, valid JSON previews with an explicit omission notice,
|
are captured in full, preserving indentation, highlighting and sensitive-field
|
||||||
preserving indentation, highlighting and sensitive-field masking. Already incomplete
|
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
|
or invalid JSON remains plain text. The visibility toggle retains its existing
|
||||||
behavior. Focus, expansion and timeline scroll position survive log updates.
|
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.
|
- 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.
|
- 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
|
## Loading in Development Mode
|
||||||
|
|
||||||
### Chrome or Microsoft Edge
|
### Chrome or Microsoft Edge
|
||||||
|
|||||||
1376
dist/chromium/background.js
vendored
1376
dist/chromium/background.js
vendored
File diff suppressed because it is too large
Load Diff
2820
dist/chromium/content.js
vendored
2820
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
16
dist/chromium/manifest.json
vendored
16
dist/chromium/manifest.json
vendored
@@ -2,10 +2,12 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Opportunities Extension",
|
"name": "Opportunities Extension",
|
||||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
"version": "1.0.2",
|
"version": "1.0.6",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
@@ -23,6 +25,16 @@
|
|||||||
],
|
],
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://spa.oracle.com/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"spa-page.js"
|
||||||
|
],
|
||||||
|
"all_frames": false,
|
||||||
|
"run_at": "document_idle"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
|
|||||||
58
dist/chromium/spa-page.js
vendored
Normal file
58
dist/chromium/spa-page.js
vendored
Normal 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();
|
||||||
|
})();
|
||||||
1376
dist/firefox/background.js
vendored
1376
dist/firefox/background.js
vendored
File diff suppressed because it is too large
Load Diff
2820
dist/firefox/content.js
vendored
2820
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
16
dist/firefox/manifest.json
vendored
16
dist/firefox/manifest.json
vendored
@@ -2,10 +2,12 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Opportunities Extension",
|
"name": "Opportunities Extension",
|
||||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
"version": "1.0.2",
|
"version": "1.0.6",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
@@ -23,6 +25,16 @@
|
|||||||
],
|
],
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://spa.oracle.com/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"spa-page.js"
|
||||||
|
],
|
||||||
|
"all_frames": false,
|
||||||
|
"run_at": "document_idle"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
|
|||||||
58
dist/firefox/spa-page.js
vendored
Normal file
58
dist/firefox/spa-page.js
vendored
Normal 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();
|
||||||
|
})();
|
||||||
@@ -14,6 +14,11 @@ preexisting risks are tracked in [refactoring.md](refactoring.md).
|
|||||||
uses `background.scripts`. Neither entry point is declared as a module.
|
uses `background.scripts`. Neither entry point is declared as a module.
|
||||||
- Content scripts run at `document_idle`, in all matching frames on the two
|
- Content scripts run at `document_idle`, in all matching frames on the two
|
||||||
configured Fusion welcome pages. Their code also checks the hostname/path.
|
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,
|
- There are no extension UI pages, injected page-context scripts, custom CSP,
|
||||||
declared execution world, or web-accessible resources.
|
declared execution world, or web-accessible resources.
|
||||||
- Permissions remain `cookies` and `storage`, with host access limited to the
|
- Permissions remain `cookies` and `storage`, with host access limited to the
|
||||||
|
|||||||
@@ -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.
|
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
|
||||||
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
|
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
|
||||||
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
|
`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
|
- Keep debug secrets hidden. Record only status codes, request names, durations
|
||||||
and sanitized errors. Do not export real tokens, response bodies or account data.
|
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
|
- AI generation and tests requiring a deliberately expired session must use an
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opportunities-extension",
|
"name": "opportunities-extension",
|
||||||
"version": "1.0.2",
|
"version": "1.0.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|||||||
const distDir = path.join(rootDir, "dist");
|
const distDir = path.join(rootDir, "dist");
|
||||||
const contentScript = composition.composeScript("content");
|
const contentScript = composition.composeScript("content");
|
||||||
const backgroundScript = composition.composeScript("background");
|
const backgroundScript = composition.composeScript("background");
|
||||||
|
const spaPageScript = composition.composeScript("spa-page");
|
||||||
const checkOnly = process.argv.includes("--check");
|
const checkOnly = process.argv.includes("--check");
|
||||||
|
|
||||||
const baseManifest = {
|
const baseManifest = {
|
||||||
manifest_version: 3,
|
manifest_version: 3,
|
||||||
name: "Opportunities Extension",
|
name: "Opportunities Extension",
|
||||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
version: "1.0.2",
|
version: "1.0.6",
|
||||||
permissions: [
|
permissions: [
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
host_permissions: [
|
host_permissions: [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
@@ -33,6 +36,12 @@ const baseManifest = {
|
|||||||
js: ["content.js"],
|
js: ["content.js"],
|
||||||
all_frames: true,
|
all_frames: true,
|
||||||
run_at: "document_idle"
|
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);
|
JSON.parse(manifestJson);
|
||||||
|
|
||||||
if (checkOnly) {
|
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)) {
|
for (const [file, contents] of Object.entries(expected)) {
|
||||||
const actual = await readFile(path.join(distDir, target.name, file), "utf8");
|
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.`);
|
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 mkdir(outputDir, { recursive: true });
|
||||||
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
||||||
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
|
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);
|
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const fs = require("node:fs");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const vm = require("node:vm");
|
const vm = require("node:vm");
|
||||||
const sources = require("./sources.cjs");
|
const sources = require("./sources.cjs");
|
||||||
|
const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js"], background: [] };
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
const root = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
@@ -16,15 +17,19 @@ function composeScript(target, { bootstrap = true } = {}) {
|
|||||||
return `// Source: ${file}\n${source}`;
|
return `// Source: ${file}\n${source}`;
|
||||||
});
|
});
|
||||||
if (target === "content") {
|
if (target === "content") {
|
||||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "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");
|
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||||
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||||
}
|
}
|
||||||
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication";
|
const factory = { content: "createContentApplication", background: "createBackgroundApplication", "spa-page": "createSpaPage" }[target];
|
||||||
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
|
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
|
||||||
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
|
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
|
||||||
new vm.Script(output, { filename: `${target}.js` });
|
new vm.Script(output, { filename: `${target}.js` });
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { composeScript, sources };
|
module.exports = { composeScript, sources, vendorSources };
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
|
"spa-page": ["src/auth/spa-page.js"],
|
||||||
"content": [
|
"content": [
|
||||||
|
"src/shared/time-dates.js",
|
||||||
|
"src/features/manage-time/view.js",
|
||||||
"src/shared/messages.js",
|
"src/shared/messages.js",
|
||||||
"src/shared/debug-body.js",
|
"src/shared/debug-body.js",
|
||||||
"src/content/contracts.js",
|
"src/content/contracts.js",
|
||||||
@@ -9,6 +12,7 @@ module.exports = {
|
|||||||
"src/features/opportunities/table-state.js",
|
"src/features/opportunities/table-state.js",
|
||||||
"src/features/opportunities/filter-state.js",
|
"src/features/opportunities/filter-state.js",
|
||||||
"src/features/preferences/state.js",
|
"src/features/preferences/state.js",
|
||||||
|
"src/features/preferences/request-cache.js",
|
||||||
"src/features/products/content-state.js",
|
"src/features/products/content-state.js",
|
||||||
"src/features/ai-summary/state.js",
|
"src/features/ai-summary/state.js",
|
||||||
"src/features/consumption/plans-state.js",
|
"src/features/consumption/plans-state.js",
|
||||||
@@ -39,11 +43,17 @@ module.exports = {
|
|||||||
"src/features/opportunities/activity-progress.js",
|
"src/features/opportunities/activity-progress.js",
|
||||||
"src/features/opportunities/activity-filters.js",
|
"src/features/opportunities/activity-filters.js",
|
||||||
"src/features/opportunities/crm-sr-popover.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-actions.js",
|
||||||
"src/features/opportunities/workload-action-form.js",
|
"src/features/opportunities/workload-action-form.js",
|
||||||
"src/features/opportunities/workload-action-options.js",
|
"src/features/opportunities/workload-action-options.js",
|
||||||
"src/features/opportunities/fiscal-periods.js",
|
"src/features/opportunities/fiscal-periods.js",
|
||||||
"src/features/opportunities/table.js",
|
"src/features/opportunities/table.js",
|
||||||
|
"src/features/opportunities/calendar.js",
|
||||||
"src/features/opportunities/dashboard.js",
|
"src/features/opportunities/dashboard.js",
|
||||||
"src/features/opportunities/filters.js",
|
"src/features/opportunities/filters.js",
|
||||||
"src/features/opportunities/formatters.js",
|
"src/features/opportunities/formatters.js",
|
||||||
@@ -51,7 +61,11 @@ module.exports = {
|
|||||||
"src/features/preferences/storage.js",
|
"src/features/preferences/storage.js",
|
||||||
"src/features/preferences/view.js",
|
"src/features/preferences/view.js",
|
||||||
"src/features/products/content-jobs.js",
|
"src/features/products/content-jobs.js",
|
||||||
|
"src/features/products/workloads.js",
|
||||||
"src/features/products/details.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/ai-summary/content.js",
|
||||||
"src/features/consumption/plans-action.js",
|
"src/features/consumption/plans-action.js",
|
||||||
"src/features/install-base/action.js",
|
"src/features/install-base/action.js",
|
||||||
@@ -75,19 +89,26 @@ module.exports = {
|
|||||||
"src/content/application.js"
|
"src/content/application.js"
|
||||||
],
|
],
|
||||||
"background": [
|
"background": [
|
||||||
|
"src/shared/time-dates.js",
|
||||||
|
"src/services/fusion/time-entries.js",
|
||||||
"src/shared/messages.js",
|
"src/shared/messages.js",
|
||||||
"src/shared/debug-body.js",
|
"src/shared/debug-body.js",
|
||||||
"src/background/contracts.js",
|
"src/background/contracts.js",
|
||||||
"src/features/products/cache-state.js",
|
"src/features/products/cache-state.js",
|
||||||
"src/auth/consumer-session.js",
|
"src/auth/consumer-session.js",
|
||||||
"src/auth/spa-credentials.js",
|
"src/auth/spa-credentials.js",
|
||||||
|
"src/auth/sr-request-headers.js",
|
||||||
"src/services/spa/accounts.js",
|
"src/services/spa/accounts.js",
|
||||||
"src/services/spa/install-base.js",
|
"src/services/spa/install-base.js",
|
||||||
"src/services/spa/customer-service-requests.js",
|
"src/services/spa/customer-service-requests.js",
|
||||||
"src/services/spa/workload-actions.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/spa/action-options.js",
|
||||||
"src/services/fusion/sr-details.js",
|
"src/services/fusion/sr-details.js",
|
||||||
|
"src/services/fusion/sr-create.js",
|
||||||
"src/services/spa/current-user.js",
|
"src/services/spa/current-user.js",
|
||||||
|
"src/services/spa/page-client.js",
|
||||||
"src/services/spa/consumption.js",
|
"src/services/spa/consumption.js",
|
||||||
"src/services/spa/response-shapes.js",
|
"src/services/spa/response-shapes.js",
|
||||||
"src/services/http/background-json.js",
|
"src/services/http/background-json.js",
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ function createAuthConsumerSession({
|
|||||||
fetchJson,
|
fetchJson,
|
||||||
supportsFirefoxCookieStoreTabs,
|
supportsFirefoxCookieStoreTabs,
|
||||||
tabsCreate,
|
tabsCreate,
|
||||||
|
tabsUpdate,
|
||||||
tabsRemove,
|
tabsRemove,
|
||||||
|
responseHeaders,
|
||||||
|
cookieChanges,
|
||||||
|
inspectPage,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
wait
|
wait
|
||||||
}) {
|
}) {
|
||||||
|
const sessionTabs = new Map();
|
||||||
async function requestConsumerTokenService(sender) {
|
async function requestConsumerTokenService(sender) {
|
||||||
const debugRequests = [];
|
const debugRequests = [];
|
||||||
|
|
||||||
@@ -150,7 +157,8 @@ function createAuthConsumerSession({
|
|||||||
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
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];
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tabsRemove(sessionTab.id);
|
await closeConsumerTokenSessionTab(sessionTab.id);
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.sessionBootstrapState = receivedValidJson
|
debugEntry.metadata.sessionBootstrapState = receivedValidJson
|
||||||
? "closed-after-valid-json"
|
? "closed-after-valid-json"
|
||||||
@@ -212,7 +220,7 @@ function createAuthConsumerSession({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openConsumerTokenSessionTab(sender) {
|
async function openConsumerTokenSessionTab(sender) {
|
||||||
const details = {
|
const details = {
|
||||||
url: CUSTOMER_TOKEN_SESSION_URL,
|
url: CUSTOMER_TOKEN_SESSION_URL,
|
||||||
active: false
|
active: false
|
||||||
@@ -226,8 +234,184 @@ function createAuthConsumerSession({
|
|||||||
details.cookieStoreId = sender.tab.cookieStoreId;
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) {
|
||||||
|
const { setTimeout } = environment;
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let allowed = false;
|
let allowed = false;
|
||||||
let userId = "";
|
let userId = "";
|
||||||
function ensure() {
|
function ensure() {
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
|
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);
|
appendBackgroundRequestLog(response?.debugRequests);
|
||||||
const roles = response?.user?.roles;
|
const user = response?.user;
|
||||||
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : "";
|
if (response?.ok === true && typeof user?.id === "string" && user.id.trim()
|
||||||
allowed = response?.ok === true && Array.isArray(roles)
|
&& typeof user.name === "string" && user.name.trim()
|
||||||
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
|
&& Array.isArray(user.roles) && user.roles.every(role => typeof role === "string" && role.trim())) {
|
||||||
|
userId = user.id;
|
||||||
|
allowed = !user.roles.includes("SALES_REP");
|
||||||
return allowed;
|
return allowed;
|
||||||
}).catch(() => { allowed = false; return false; });
|
}
|
||||||
|
} catch (error) {
|
||||||
|
appendBackgroundRequestLog(error?.debugRequests);
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||||
|
retryDelay = Math.min(retryDelay * 2, 10000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/auth/spa-page.js
Normal file
52
src/auth/spa-page.js
Normal 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 };
|
||||||
|
}
|
||||||
131
src/auth/sr-request-headers.js
Normal file
131
src/auth/sr-request-headers.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -92,10 +92,7 @@ function createAuthTokenRelay({
|
|||||||
throw new Error("Token relay response does not contain access_token.");
|
throw new Error("Token relay response does not contain access_token.");
|
||||||
}
|
}
|
||||||
|
|
||||||
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
|
logRequestSuccess(requestEntry, response, responseBody);
|
||||||
...responseData,
|
|
||||||
access_token: maskToken(responseData.access_token)
|
|
||||||
}, null, 2)));
|
|
||||||
|
|
||||||
if (url === TOKEN_RELAY_URL) {
|
if (url === TOKEN_RELAY_URL) {
|
||||||
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
|
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
|
||||||
@@ -122,7 +119,7 @@ function createAuthTokenRelay({
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
|
"x-xsrf-token": xsrfTokenSource.token || "(missing)"
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",
|
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",
|
||||||
|
|||||||
@@ -4,12 +4,25 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
browser: globalThis.browser,
|
browser: globalThis.browser,
|
||||||
chrome: globalThis.chrome,
|
chrome: globalThis.chrome,
|
||||||
setTimeout: globalThis.setTimeout,
|
setTimeout: globalThis.setTimeout,
|
||||||
|
clearTimeout: globalThis.clearTimeout,
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
const messageTypes = createMessageTypes();
|
const messageTypes = createMessageTypes();
|
||||||
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
|
||||||
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
|
||||||
const contracts = createBackgroundContracts();
|
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 productsCacheState = createProductsCacheState();
|
||||||
const authConsumerSession = createAuthConsumerSession({
|
const authConsumerSession = createAuthConsumerSession({
|
||||||
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
|
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
|
||||||
@@ -21,7 +34,13 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
|
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
|
||||||
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
|
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
|
||||||
|
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
|
||||||
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...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)
|
wait: (...args) => platformBackgroundApi.wait(...args)
|
||||||
});
|
});
|
||||||
const authSpaCredentials = createAuthSpaCredentials({
|
const authSpaCredentials = createAuthSpaCredentials({
|
||||||
@@ -56,8 +75,8 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
});
|
});
|
||||||
const spaResponseShapes = createSpaResponseShapes();
|
const spaResponseShapes = createSpaResponseShapes();
|
||||||
const httpBackgroundJson = createHttpBackgroundJson({
|
const httpBackgroundJson = createHttpBackgroundJson({
|
||||||
|
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
|
||||||
previewDebugBody: createDebugBodyPreview,
|
previewDebugBody: createDebugBodyPreview,
|
||||||
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
|
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const productsBackgroundJobs = createProductsBackgroundJobs({
|
const productsBackgroundJobs = createProductsBackgroundJobs({
|
||||||
@@ -114,6 +133,17 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||||
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...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 requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||||
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||||
@@ -125,9 +155,42 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
|
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
|
||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
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() {
|
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 {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
@@ -142,6 +205,8 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
httpBackgroundJson,
|
httpBackgroundJson,
|
||||||
productsBackgroundJobs,
|
productsBackgroundJobs,
|
||||||
productsCache,
|
productsCache,
|
||||||
|
workloadsCache,
|
||||||
|
spaWorkloads,
|
||||||
fusionProducts,
|
fusionProducts,
|
||||||
authXsrfRecovery,
|
authXsrfRecovery,
|
||||||
authXsrfCookies,
|
authXsrfCookies,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function createBackgroundContracts() {
|
|||||||
const OPPTY_PRODUCTS_MAX_PAGES = 250;
|
const OPPTY_PRODUCTS_MAX_PAGES = 250;
|
||||||
const OPPTY_PRODUCTS_CONCURRENCY = 4;
|
const OPPTY_PRODUCTS_CONCURRENCY = 4;
|
||||||
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService";
|
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_MAX_ATTEMPTS = 5;
|
||||||
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
|
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
|
||||||
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
|
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
requestWeekDetails,
|
||||||
|
requestTimeEntries,
|
||||||
|
requestTimeEntriesSave,
|
||||||
|
requestTimeEntriesDelete,
|
||||||
|
requestLookupTaskType,
|
||||||
|
requestServiceRequestExpand,
|
||||||
|
requestResourceUser,
|
||||||
|
requestActiveServiceRequests,
|
||||||
invalidateProductsCache,
|
invalidateProductsCache,
|
||||||
|
invalidateResourceUser = async () => {},
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
requestInstallBase,
|
requestInstallBase,
|
||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestWlActions,
|
requestWlActions,
|
||||||
|
requestWorkloads,
|
||||||
|
requestWorkloadForecastUpdate,
|
||||||
|
invalidateWorkloadsCache,
|
||||||
requestActionTeamMembers,
|
requestActionTeamMembers,
|
||||||
requestActionFindLookup,
|
requestActionFindLookup,
|
||||||
requestMgmtAction,
|
requestMgmtAction,
|
||||||
|
requestPostServiceRequest,
|
||||||
|
requestPostInternalServiceRequest,
|
||||||
requestInternalServiceRequest,
|
requestInternalServiceRequest,
|
||||||
|
requestSrLookupPrimaryPillar,
|
||||||
|
requestSrLookupWithSession,
|
||||||
|
prepareSrSession,
|
||||||
|
requestSrLookupLanguage,
|
||||||
|
requestSrLookupTechPlatform,
|
||||||
|
requestLookupTypePlatform,
|
||||||
|
requestInternalSrServices,
|
||||||
|
requestSrLookupOpportunityBusiness,
|
||||||
requestCurrentUser,
|
requestCurrentUser,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
@@ -24,9 +46,58 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
return false;
|
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) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
if (message.type === messageTypes.getXsrfToken) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
@@ -99,7 +170,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestCurrentUser) {
|
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.",
|
ok: false, error: error.message || "Unable to load current user.",
|
||||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
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)) {
|
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;
|
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.",
|
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
|
||||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
}));
|
}));
|
||||||
return true;
|
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) {
|
if (message.type === messageTypes.requestWlActions) {
|
||||||
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
||||||
ok: false, error: error.message || "Unable to load workload actions.",
|
ok: false, error: error.message || "Unable to load workload actions.",
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ function createContentApplication(overrides = {}) {
|
|||||||
};
|
};
|
||||||
const messageTypes = createMessageTypes();
|
const messageTypes = createMessageTypes();
|
||||||
const currentUser = createCurrentUser({
|
const currentUser = createCurrentUser({
|
||||||
|
environment,
|
||||||
messageTypes,
|
messageTypes,
|
||||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
|
||||||
});
|
});
|
||||||
const contracts = createContentContracts();
|
const contracts = createContentContracts();
|
||||||
|
const searchableSelect = createSearchableSelect({ environment });
|
||||||
const authSessionState = createAuthSessionState(contracts);
|
const authSessionState = createAuthSessionState(contracts);
|
||||||
const authSalesCommonState = createAuthSalesCommonState();
|
const authSalesCommonState = createAuthSalesCommonState();
|
||||||
const opportunitiesQueryState = createOpportunitiesQueryState();
|
const opportunitiesQueryState = createOpportunitiesQueryState();
|
||||||
@@ -55,7 +57,10 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const contentModal = createContentModal({
|
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),
|
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
|
||||||
closeCrmSrPopover: () => crmSrPopover.close(),
|
closeCrmSrPopover: () => crmSrPopover.close(),
|
||||||
ensureCurrentUser: () => currentUser.ensure(),
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
@@ -132,6 +137,58 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const svgPaths = createSvgPaths({ 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({
|
const contentStyles = createContentStyles({
|
||||||
STYLE_ID: contracts.STYLE_ID,
|
STYLE_ID: contracts.STYLE_ID,
|
||||||
redwoodStyles,
|
redwoodStyles,
|
||||||
@@ -170,12 +227,14 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const platformContentMessages = createPlatformContentMessages({
|
const platformContentMessages = createPlatformContentMessages({
|
||||||
|
requestCache: createFormRequestCache({ getHours: () => preferencesState.opptyProductsCacheHours, getUserId: () => currentUser.getUserId() }),
|
||||||
messageTypes,
|
messageTypes,
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const opportunitiesController = createOpportunitiesController({
|
const opportunitiesController = createOpportunitiesController({
|
||||||
ensureCurrentUser: () => currentUser.ensure(),
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
||||||
|
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
|
||||||
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||||
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
||||||
authSessionState,
|
authSessionState,
|
||||||
@@ -216,19 +275,67 @@ function createContentApplication(overrides = {}) {
|
|||||||
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||||
maskToken: (...args) => debugContentLog.maskToken(...args), environment
|
maskToken: (...args) => debugContentLog.maskToken(...args), environment
|
||||||
});
|
});
|
||||||
const crmSrPopover = createCrmSrPopover({
|
const internalSrSession = createInternalSrSession({
|
||||||
requestDetails: async srId => {
|
environment,
|
||||||
|
query: async srId => {
|
||||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
|
||||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
if (!result?.ok) throw new Error(result?.error || "Unable to load SR details.");
|
return result;
|
||||||
return result.items;
|
}
|
||||||
},
|
});
|
||||||
|
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,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
||||||
});
|
});
|
||||||
const opportunityBadgeContent = createOpportunityBadgeContent({ 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({
|
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),
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
activityCache,
|
activityCache,
|
||||||
reportProgress: (...args) => activityProgress.update("sr", ...args),
|
reportProgress: (...args) => activityProgress.update("sr", ...args),
|
||||||
@@ -238,7 +345,25 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
||||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
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({
|
const opportunitiesTable = createOpportunitiesTable({
|
||||||
|
renderCalendar: (...args) => opportunityCalendar.render(...args),
|
||||||
renderActivityFilters: () => activityFilters.render(),
|
renderActivityFilters: () => activityFilters.render(),
|
||||||
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
||||||
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
||||||
@@ -253,6 +378,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||||
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
||||||
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
|
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
|
||||||
|
createProjectionIndicator: (...args) => rampProjectionView.createOpportunityIndicator(...args),
|
||||||
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
|
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
|
||||||
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
|
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
|
||||||
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
|
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
|
||||||
@@ -352,6 +478,8 @@ function createContentApplication(overrides = {}) {
|
|||||||
resetRelatedData: () => {
|
resetRelatedData: () => {
|
||||||
opportunityCrmServiceRequests.reset();
|
opportunityCrmServiceRequests.reset();
|
||||||
opportunityWorkloadActions.reset();
|
opportunityWorkloadActions.reset();
|
||||||
|
productWorkloads.reset();
|
||||||
|
rampProjectionView.close(false);
|
||||||
consumptionPlansView.closeAccountPlansModal();
|
consumptionPlansView.closeAccountPlansModal();
|
||||||
installBaseView.closeInstallBaseModal();
|
installBaseView.closeInstallBaseModal();
|
||||||
serviceRequestsView.closeServiceRequestsModal();
|
serviceRequestsView.closeServiceRequestsModal();
|
||||||
@@ -364,6 +492,18 @@ function createContentApplication(overrides = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
|
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({
|
const activityFilters = createActivityFilters({
|
||||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
@@ -383,6 +523,8 @@ function createContentApplication(overrides = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const productsContentJobs = createProductsContentJobs({
|
const productsContentJobs = createProductsContentJobs({
|
||||||
|
loadWorkloads: (...args) => productWorkloads.load(...args),
|
||||||
|
joinWorkloads: number => productWorkloads.joinProducts(number),
|
||||||
beginProductActivities: (...args) => productActivities.begin(...args),
|
beginProductActivities: (...args) => productActivities.begin(...args),
|
||||||
completeProductActivities: (...args) => productActivities.complete(...args),
|
completeProductActivities: (...args) => productActivities.complete(...args),
|
||||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
@@ -396,6 +538,14 @@ function createContentApplication(overrides = {}) {
|
|||||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
environment
|
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({
|
const workloadActionOptions = createWorkloadActionOptions({
|
||||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
@@ -403,6 +553,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
messageTypes
|
messageTypes
|
||||||
});
|
});
|
||||||
const workloadActionForm = createWorkloadActionForm({
|
const workloadActionForm = createWorkloadActionForm({
|
||||||
|
searchableSelect,
|
||||||
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
|
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
|
||||||
saveAction: (...args) => workloadActionOptions.save(...args),
|
saveAction: (...args) => workloadActionOptions.save(...args),
|
||||||
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
@@ -433,6 +584,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
productsContentState, messageTypes, environment
|
productsContentState, messageTypes, environment
|
||||||
});
|
});
|
||||||
const productsDetails = createProductsDetails({
|
const productsDetails = createProductsDetails({
|
||||||
|
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
|
||||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
OPPORTUNITIES_COLUMNS: columns,
|
OPPORTUNITIES_COLUMNS: columns,
|
||||||
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
||||||
@@ -442,6 +594,30 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
|
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
|
||||||
environment
|
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({
|
const aiSummaryContent = createAiSummaryContent({
|
||||||
aiSummaryState,
|
aiSummaryState,
|
||||||
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
|
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
|
||||||
@@ -579,7 +755,6 @@ function createContentApplication(overrides = {}) {
|
|||||||
});
|
});
|
||||||
const debugContentLog = createDebugContentLog({
|
const debugContentLog = createDebugContentLog({
|
||||||
previewDebugBody: createDebugBodyPreview,
|
previewDebugBody: createDebugBodyPreview,
|
||||||
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
|
|
||||||
debugState,
|
debugState,
|
||||||
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
|
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
|
||||||
});
|
});
|
||||||
@@ -597,6 +772,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
opportunityCalendar,
|
||||||
workloadActionForm,
|
workloadActionForm,
|
||||||
activityFilters,
|
activityFilters,
|
||||||
start, contracts,
|
start, contracts,
|
||||||
@@ -636,11 +812,16 @@ function createContentApplication(overrides = {}) {
|
|||||||
preferencesStorage,
|
preferencesStorage,
|
||||||
preferencesView,
|
preferencesView,
|
||||||
productsContentJobs,
|
productsContentJobs,
|
||||||
|
productWorkloads,
|
||||||
productActivities,
|
productActivities,
|
||||||
activityProgress,
|
activityProgress,
|
||||||
|
srSessionPreparation,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
activityCache,
|
activityCache,
|
||||||
productsDetails,
|
productsDetails,
|
||||||
|
rampProjection,
|
||||||
|
workloadForecastUpdate,
|
||||||
|
rampProjectionView,
|
||||||
aiSummaryContent,
|
aiSummaryContent,
|
||||||
consumptionPlansAction,
|
consumptionPlansAction,
|
||||||
installBaseAction,
|
installBaseAction,
|
||||||
|
|||||||
@@ -89,8 +89,7 @@ function createContentContracts() {
|
|||||||
"Next Quarter",
|
"Next Quarter",
|
||||||
"Previous Quarter",
|
"Previous Quarter",
|
||||||
"Current Fiscal Year",
|
"Current Fiscal Year",
|
||||||
"4 Rolling Quarters (CQ + 3)",
|
"4 Rolling Quarters (CQ + 3)"
|
||||||
"RENEWALS (Current + Past Due)"
|
|
||||||
];
|
];
|
||||||
const ALLOWED_PATHS = [
|
const ALLOWED_PATHS = [
|
||||||
"/hcmUI/faces/FuseWelcome",
|
"/hcmUI/faces/FuseWelcome",
|
||||||
|
|||||||
@@ -85,5 +85,67 @@ function createContentIcons({
|
|||||||
svg.append(path);
|
svg.append(path);
|
||||||
return svg;
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ function createContentModal({
|
|||||||
closePreferencesModal,
|
closePreferencesModal,
|
||||||
createSettingsIcon,
|
createSettingsIcon,
|
||||||
createRefreshAllButton,
|
createRefreshAllButton,
|
||||||
|
createManageTimeButton,
|
||||||
createActivityFilterControls,
|
createActivityFilterControls,
|
||||||
|
mountOpportunityViews,
|
||||||
debugState,
|
debugState,
|
||||||
ensureExtensionStyles,
|
ensureExtensionStyles,
|
||||||
getCustomerOptions,
|
getCustomerOptions,
|
||||||
@@ -57,6 +59,7 @@ function createContentModal({
|
|||||||
closeCrmSrPopover,
|
closeCrmSrPopover,
|
||||||
requestTokenRelayOnce,
|
requestTokenRelayOnce,
|
||||||
ensureCurrentUser,
|
ensureCurrentUser,
|
||||||
|
prepareSrSession,
|
||||||
resetCustomerAndOwnerFiltersForPeriod,
|
resetCustomerAndOwnerFiltersForPeriod,
|
||||||
resetOpportunitiesTable,
|
resetOpportunitiesTable,
|
||||||
restoreFilterPreferences,
|
restoreFilterPreferences,
|
||||||
@@ -83,6 +86,7 @@ function createContentModal({
|
|||||||
|
|
||||||
if (existingModal) {
|
if (existingModal) {
|
||||||
existingModal.hidden = false;
|
existingModal.hidden = false;
|
||||||
|
prepareSrSession?.();
|
||||||
existingModal.querySelector("select").focus();
|
existingModal.querySelector("select").focus();
|
||||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||||
ensureEscapeKeyHandler();
|
ensureEscapeKeyHandler();
|
||||||
@@ -173,7 +177,7 @@ function createContentModal({
|
|||||||
|
|
||||||
const headerActions = document.createElement("div");
|
const headerActions = document.createElement("div");
|
||||||
headerActions.className = "opportunities-extension-header-actions";
|
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);
|
header.append(titleBlock, headerActions);
|
||||||
|
|
||||||
@@ -401,7 +405,7 @@ function createContentModal({
|
|||||||
opportunitiesTable.setAttribute("aria-label", "Opportunities");
|
opportunitiesTable.setAttribute("aria-label", "Opportunities");
|
||||||
tableSurface.append(opportunitiesTable);
|
tableSurface.append(opportunitiesTable);
|
||||||
|
|
||||||
body.append(tableSurface);
|
mountOpportunityViews(body, tableSurface);
|
||||||
|
|
||||||
const debugPanel = document.createElement("aside");
|
const debugPanel = document.createElement("aside");
|
||||||
debugPanel.id = DEBUG_PANEL_ID;
|
debugPanel.id = DEBUG_PANEL_ID;
|
||||||
@@ -420,6 +424,7 @@ function createContentModal({
|
|||||||
renderOwnerFilter();
|
renderOwnerFilter();
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
requestOpportunitiesForPeriod(select.value);
|
requestOpportunitiesForPeriod(select.value);
|
||||||
|
prepareSrSession?.();
|
||||||
select.focus();
|
select.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
function createDebugContentLog({
|
function createDebugContentLog({
|
||||||
previewDebugBody,
|
previewDebugBody,
|
||||||
DEBUG_BODY_LIMIT,
|
|
||||||
debugState,
|
debugState,
|
||||||
renderDebugPanel
|
renderDebugPanel
|
||||||
}) {
|
}) {
|
||||||
@@ -99,7 +98,7 @@ function createDebugContentLog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDebugBody(text) {
|
function createDebugBody(text) {
|
||||||
return previewDebugBody(text, DEBUG_BODY_LIMIT);
|
return previewDebugBody(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function headersToObject(headers) {
|
function headersToObject(headers) {
|
||||||
@@ -126,7 +125,7 @@ function createDebugContentLog({
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDebugBody(body) {
|
function formatDebugBody(body, requestName = "") {
|
||||||
const text = body || "(none)";
|
const text = body || "(none)";
|
||||||
try {
|
try {
|
||||||
const normalized = String(text).replace(/^\uFEFF/, "").trimStart()
|
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) {
|
for (let depth = 0; depth < 2 && typeof value === "string" && /^[\s]*[\[{]/.test(value); depth += 1) {
|
||||||
try { value = JSON.parse(value); } catch { break; }
|
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);
|
return JSON.stringify(debugState.debugSecretsVisible ? value : maskSensitiveDebugValue(value), null, 2);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!debugState.debugSecretsVisible && /^(requestSrTokenCatalog|requestCurrentUser|requestTokenRelay.*)$/.test(requestName)) return formatHiddenDebugValue(text);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +167,7 @@ function createDebugContentLog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSensitiveDebugKey(key) {
|
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) {
|
function formatHiddenDebugValue(value) {
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ function createDebugContentView({
|
|||||||
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
||||||
if (entry.name === "requestGenerateAiSummary") return "ai";
|
if (entry.name === "requestGenerateAiSummary") return "ai";
|
||||||
if (entry.name === "requestInstallBase") return "installBase";
|
if (entry.name === "requestInstallBase") return "installBase";
|
||||||
if (["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";
|
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
@@ -249,7 +250,7 @@ function createDebugContentView({
|
|||||||
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
|
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
|
||||||
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
|
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
|
||||||
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
|
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);
|
item.append(heading, details);
|
||||||
list.append(item);
|
list.append(item);
|
||||||
|
|||||||
64
src/features/manage-time/styles.css
Normal file
64
src/features/manage-time/styles.css
Normal 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; } }
|
||||||
525
src/features/manage-time/view.js
Normal file
525
src/features/manage-time/view.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ function createActivityProgress({ MODAL_ID, environment }) {
|
|||||||
clearTimeout(entry.timer);
|
clearTimeout(entry.timer);
|
||||||
clearTimeout(entry.hideTimer);
|
clearTimeout(entry.hideTimer);
|
||||||
entry.state = state;
|
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.hidden = state === "idle";
|
||||||
entry.badge.setAttribute("data-state", state);
|
entry.badge.setAttribute("data-state", state);
|
||||||
entry.badge.setAttribute("data-fading", "false");
|
entry.badge.setAttribute("data-fading", "false");
|
||||||
@@ -27,7 +27,8 @@ function createActivityProgress({ MODAL_ID, environment }) {
|
|||||||
entry.badge.textContent = state === "error" ? `${label} unavailable`
|
entry.badge.textContent = state === "error" ? `${label} unavailable`
|
||||||
: state === "complete-with-errors" ? `${label} ${count} (errors)`
|
: state === "complete-with-errors" ? `${label} ${count} (errors)`
|
||||||
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
|
: 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(() => {
|
if (state === "complete") entry.timer = setTimeout(() => {
|
||||||
entry.badge.setAttribute("data-fading", "true");
|
entry.badge.setAttribute("data-fading", "true");
|
||||||
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|||||||
105
src/features/opportunities/calendar.css
Normal file
105
src/features/opportunities/calendar.css
Normal 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; }
|
||||||
|
}
|
||||||
292
src/features/opportunities/calendar.js
Normal file
292
src/features/opportunities/calendar.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ function createOpportunitiesController({
|
|||||||
requestOpportunitiesPage,
|
requestOpportunitiesPage,
|
||||||
resetCrmServiceRequests,
|
resetCrmServiceRequests,
|
||||||
resetWorkloadActions,
|
resetWorkloadActions,
|
||||||
|
resetWorkloads,
|
||||||
requestOpptyProductsInBackground,
|
requestOpptyProductsInBackground,
|
||||||
requestTokenRelayOnce,
|
requestTokenRelayOnce,
|
||||||
ensureCurrentUser,
|
ensureCurrentUser,
|
||||||
@@ -23,6 +24,8 @@ function createOpportunitiesController({
|
|||||||
async function requestOpportunitiesForPeriod(period) {
|
async function requestOpportunitiesForPeriod(period) {
|
||||||
const dateRange = getFiscalPeriodRange(period, new Date());
|
const dateRange = getFiscalPeriodRange(period, new Date());
|
||||||
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
|
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
|
||||||
|
resetWorkloadActions();
|
||||||
|
resetWorkloads();
|
||||||
await ensureCurrentUser();
|
await ensureCurrentUser();
|
||||||
if (!isCurrentPeriodRequest(requestVersion)) return;
|
if (!isCurrentPeriodRequest(requestVersion)) return;
|
||||||
|
|
||||||
@@ -108,6 +111,7 @@ function createOpportunitiesController({
|
|||||||
|
|
||||||
function resetOpportunitiesTable() {
|
function resetOpportunitiesTable() {
|
||||||
resetWorkloadActions();
|
resetWorkloadActions();
|
||||||
|
resetWorkloads();
|
||||||
resetCrmServiceRequests();
|
resetCrmServiceRequests();
|
||||||
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
|
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
|
||||||
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
|
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
|
||||||
|
|||||||
@@ -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 status = "idle";
|
||||||
let byOpportunity = new Map();
|
let byOpportunity = new Map();
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
let loadedItems = [];
|
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) {
|
function load(token, version, cacheOptions = null) {
|
||||||
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
||||||
if (status === "ready") {
|
if (status === "ready") {
|
||||||
@@ -79,6 +79,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
`Title: ${display(item.Title)}`,
|
`Title: ${display(item.Title)}`,
|
||||||
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
|
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
|
||||||
`Reported Hours: ${display(item.ReportedHours_c)}`,
|
`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"}`,
|
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
|
||||||
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
|
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
|
||||||
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
`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.setAttribute("aria-label", description);
|
||||||
badge.title = "Whether a Service Request has been created for the opportunities.";
|
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") bindPopover(badge, items, descriptions);
|
||||||
|
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
|
||||||
badge.tabIndex = 0;
|
badge.tabIndex = 0;
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|||||||
347
src/features/opportunities/crm-sr-form.js
Normal file
347
src/features/opportunities/crm-sr-form.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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;
|
const { document, window, setTimeout, clearTimeout } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
@@ -91,6 +91,29 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
|||||||
label.textContent = "SR Number:";
|
label.textContent = "SR Number:";
|
||||||
heading.append(label, link);
|
heading.append(label, link);
|
||||||
if (number) heading.append(createCopyButton(String(number), "SR number", "SR Number"));
|
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");
|
const details = document.createElement("div");
|
||||||
details.className = "opportunities-extension-sr-details";
|
details.className = "opportunities-extension-sr-details";
|
||||||
details.textContent = descriptions[index];
|
details.textContent = descriptions[index];
|
||||||
@@ -126,7 +149,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
|||||||
body.className = "opportunities-extension-sr-details";
|
body.className = "opportunities-extension-sr-details";
|
||||||
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
|
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
|
||||||
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
|
"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);
|
child.append(row, body);
|
||||||
children.append(child);
|
children.append(child);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
function createOpportunitiesFiscalPeriods() {
|
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) {
|
function getFiscalPeriodRange(period, currentDate) {
|
||||||
const currentYear = currentDate.getFullYear();
|
const currentYear = currentDate.getFullYear();
|
||||||
const currentMonth = currentDate.getMonth();
|
const currentMonth = currentDate.getMonth();
|
||||||
const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1;
|
const fiscalStartYear = currentMonth >= FISCAL_START_MONTH ? currentYear : currentYear - 1;
|
||||||
const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3);
|
const currentQuarterIndex = Math.floor(((currentMonth - FISCAL_START_MONTH + 12) % 12) / 3);
|
||||||
|
|
||||||
if (period === "Current Fiscal Year") {
|
if (period === "Current Fiscal Year") {
|
||||||
const isBeforeFiscalYearStart = currentMonth < 5;
|
return createDateRange(fiscalStartYear, FISCAL_START_MONTH, fiscalStartYear + 1, FISCAL_START_MONTH);
|
||||||
const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear;
|
|
||||||
const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1;
|
|
||||||
return createDateRange(startYear, 5, endYear, 4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let quarterOffset;
|
let quarterOffset;
|
||||||
@@ -28,19 +28,14 @@ function createOpportunitiesFiscalPeriods() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1);
|
const startMonth = FISCAL_START_MONTH + (quarterOffset * 3);
|
||||||
const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0);
|
return createDateRange(fiscalStartYear, startMonth, fiscalStartYear, startMonth + (numberOfQuarters * 3));
|
||||||
|
|
||||||
return {
|
|
||||||
startCloseDate: formatOracleDate(start),
|
|
||||||
endCloseDate: formatOracleDate(end)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDateRange(startYear, startMonth, endYear, endMonth) {
|
function createDateRange(startYear, startMonth, endYear, endMonth) {
|
||||||
return {
|
return {
|
||||||
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
|
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
|
||||||
endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0))
|
endCloseDate: formatOracleDate(new Date(endYear, endMonth, 1))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
197
src/features/opportunities/internal-sr-form.js
Normal file
197
src/features/opportunities/internal-sr-form.js
Normal 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 };
|
||||||
|
}
|
||||||
60
src/features/opportunities/internal-sr-session.js
Normal file
60
src/features/opportunities/internal-sr-session.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
function createProductActivities({ productsContentState, opportunitiesQueryState,
|
function createProductActivities({ productsContentState, opportunitiesQueryState,
|
||||||
loadCrm, loadActions, blockActions, render }) {
|
loadCrm, loadActions, blockActions, render }) {
|
||||||
let cycle = null;
|
let cycle = null;
|
||||||
function begin(requestId, token) {
|
function begin(requestId, token, opportunityNumbers = null) {
|
||||||
cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false };
|
cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), version: opportunitiesQueryState.periodRequestVersion, completed: false };
|
||||||
}
|
}
|
||||||
function complete(requestId) {
|
function complete(requestId) {
|
||||||
const current = cycle;
|
const current = cycle;
|
||||||
if (!current || current.completed || current.requestId !== requestId
|
if (!current || current.completed || current.requestId !== requestId
|
||||||
|| productsContentState.currentProductsRequestId !== requestId) return;
|
|| productsContentState.currentProductsRequestId !== requestId) return;
|
||||||
current.completed = true;
|
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;
|
if (!products.length) return;
|
||||||
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
|
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
|
||||||
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
|
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
|
||||||
&& opportunitiesQueryState.periodRequestVersion === current.version;
|
&& 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 (!isActive()) return;
|
||||||
if (success === false) {
|
if (success === false) {
|
||||||
products.forEach(blockActions);
|
products.forEach(blockActions);
|
||||||
@@ -30,7 +32,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
|
|||||||
seen.add(id);
|
seen.add(id);
|
||||||
return true;
|
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);
|
await Promise.allSettled(pending);
|
||||||
if (!isActive()) return;
|
if (!isActive()) return;
|
||||||
|
|||||||
175
src/features/opportunities/searchable-select.js
Normal file
175
src/features/opportunities/searchable-select.js
Normal 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;
|
||||||
|
}
|
||||||
22
src/features/opportunities/sr-session-preparation.js
Normal file
22
src/features/opportunities/sr-session-preparation.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -309,6 +309,36 @@
|
|||||||
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
|
.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 { height: 38px; }
|
||||||
.opportunities-extension-action-form-field select[hidden] { display: none; }
|
.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-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[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; }
|
.opportunities-extension-modal .opportunities-extension-copy-toast { z-index: 20; }
|
||||||
@@ -585,3 +615,5 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; }
|
.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%); }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ function createOpportunitiesTable({
|
|||||||
createDetailLink,
|
createDetailLink,
|
||||||
createProductsCountButton,
|
createProductsCountButton,
|
||||||
createProductsRampBadge,
|
createProductsRampBadge,
|
||||||
|
createProjectionIndicator,
|
||||||
createCrmServiceRequestBadge,
|
createCrmServiceRequestBadge,
|
||||||
closeCrmSrPopover,
|
closeCrmSrPopover,
|
||||||
createWorkloadActionsBadge,
|
createWorkloadActionsBadge,
|
||||||
@@ -25,6 +26,7 @@ function createOpportunitiesTable({
|
|||||||
renderOwnerFilter,
|
renderOwnerFilter,
|
||||||
renderActivityFilters,
|
renderActivityFilters,
|
||||||
renderStageDashboard,
|
renderStageDashboard,
|
||||||
|
renderCalendar,
|
||||||
resetCustomerFilter,
|
resetCustomerFilter,
|
||||||
resetOwnerFilter,
|
resetOwnerFilter,
|
||||||
environment
|
environment
|
||||||
@@ -102,6 +104,7 @@ function createOpportunitiesTable({
|
|||||||
const tableBody = document.createElement("tbody");
|
const tableBody = document.createElement("tbody");
|
||||||
const items = getSortedOpportunities();
|
const items = getSortedOpportunities();
|
||||||
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
|
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)) {
|
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");
|
const row = document.createElement("tr");
|
||||||
@@ -144,6 +147,9 @@ function createOpportunitiesTable({
|
|||||||
if (srBadge) productActions.append(srBadge);
|
if (srBadge) productActions.append(srBadge);
|
||||||
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
|
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
|
||||||
if (actionsBadge) productActions.append(actionsBadge);
|
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);
|
nameCellContent.append(opportunityLink, productActions);
|
||||||
cell.append(nameCellContent);
|
cell.append(nameCellContent);
|
||||||
} else if (column.key === "optyNumber" && item.OptyNumber) {
|
} else if (column.key === "optyNumber" && item.OptyNumber) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
|
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;
|
const { document } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
function element(tag, className, text) {
|
function element(tag, className, text) {
|
||||||
@@ -8,109 +8,6 @@ function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingActi
|
|||||||
if (text !== undefined) node.textContent = text;
|
if (text !== undefined) node.textContent = text;
|
||||||
return node;
|
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) {
|
function close(restoreFocus = true) {
|
||||||
if (!active) return false;
|
if (!active) return false;
|
||||||
if (active.saving) return true;
|
if (active.saving) return true;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
|||||||
for (const id of idsFor(products)) {
|
for (const id of idsFor(products)) {
|
||||||
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
|
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
|
||||||
}
|
}
|
||||||
|
updateProgress(session);
|
||||||
}
|
}
|
||||||
function pump(current) {
|
function pump(current) {
|
||||||
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
|
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."
|
].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.";
|
: 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.";
|
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.";
|
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.";
|
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.";
|
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
|
||||||
|
|||||||
69
src/features/preferences/request-cache.js
Normal file
69
src/features/preferences/request-cache.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -102,11 +102,11 @@ function createPreferencesView({
|
|||||||
cacheField.className = "opportunities-extension-preferences-cache-field";
|
cacheField.className = "opportunities-extension-preferences-cache-field";
|
||||||
|
|
||||||
const cacheLabel = document.createElement("span");
|
const cacheLabel = document.createElement("span");
|
||||||
cacheLabel.textContent = "Product cache duration";
|
cacheLabel.textContent = "Cache duration";
|
||||||
|
|
||||||
const cacheSelect = document.createElement("select");
|
const cacheSelect = document.createElement("select");
|
||||||
cacheSelect.name = "opptyProductsCacheHours";
|
cacheSelect.name = "opptyProductsCacheHours";
|
||||||
cacheSelect.setAttribute("aria-label", "Product cache duration");
|
cacheSelect.setAttribute("aria-label", "Cache duration");
|
||||||
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
|
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
|
||||||
const optionElement = document.createElement("option");
|
const optionElement = document.createElement("option");
|
||||||
optionElement.value = String(option.value);
|
optionElement.value = String(option.value);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ function createProductsContentJobs({
|
|||||||
renderOpportunitiesTable,
|
renderOpportunitiesTable,
|
||||||
beginProductActivities,
|
beginProductActivities,
|
||||||
completeProductActivities,
|
completeProductActivities,
|
||||||
|
loadWorkloads,
|
||||||
|
joinWorkloads,
|
||||||
sendRuntimeMessage,
|
sendRuntimeMessage,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
@@ -53,7 +55,7 @@ function createProductsContentJobs({
|
|||||||
|
|
||||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
productsContentState.currentProductsRequestId = requestId;
|
productsContentState.currentProductsRequestId = requestId;
|
||||||
beginProductActivities(requestId, token);
|
beginProductActivities(requestId, token, optyNumbers);
|
||||||
productsContentState.opptyProductsByNumber = new Map();
|
productsContentState.opptyProductsByNumber = new Map();
|
||||||
productsContentState.expandedProductOptyNumbers = new Set();
|
productsContentState.expandedProductOptyNumbers = new Set();
|
||||||
productsContentState.productsProgress = {
|
productsContentState.productsProgress = {
|
||||||
@@ -65,6 +67,8 @@ function createProductsContentJobs({
|
|||||||
};
|
};
|
||||||
renderProductsProgress();
|
renderProductsProgress();
|
||||||
|
|
||||||
|
loadWorkloads(opportunities, requestId);
|
||||||
|
|
||||||
if (optyNumbers.length === 0) {
|
if (optyNumbers.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -121,6 +125,7 @@ function createProductsContentJobs({
|
|||||||
cachedAt: Number(message.cachedAt) || 0,
|
cachedAt: Number(message.cachedAt) || 0,
|
||||||
error: message.error || ""
|
error: message.error || ""
|
||||||
});
|
});
|
||||||
|
joinWorkloads(message.optyNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
productsContentState.productsProgress = {
|
productsContentState.productsProgress = {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ function createProductsContentState() {
|
|||||||
currentProductsRequestId: "",
|
currentProductsRequestId: "",
|
||||||
productsRenderQueued: false,
|
productsRenderQueued: false,
|
||||||
opptyProductsByNumber: new Map(),
|
opptyProductsByNumber: new Map(),
|
||||||
|
workloadCustomerIds: [],
|
||||||
|
workloadsByCustomer: new Map(),
|
||||||
expandedProductOptyNumbers: new Set(),
|
expandedProductOptyNumbers: new Set(),
|
||||||
productsProgress: {
|
productsProgress: {
|
||||||
state: "idle",
|
state: "idle",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ function createProductsDetails({
|
|||||||
OPPORTUNITIES_COLUMNS,
|
OPPORTUNITIES_COLUMNS,
|
||||||
setBadgeContent,
|
setBadgeContent,
|
||||||
createAiSummaryAlert,
|
createAiSummaryAlert,
|
||||||
|
createProjectionIndicator,
|
||||||
formatOracleResponseDate,
|
formatOracleResponseDate,
|
||||||
productsContentState,
|
productsContentState,
|
||||||
renderOpportunitiesTable,
|
renderOpportunitiesTable,
|
||||||
@@ -18,12 +19,20 @@ function createProductsDetails({
|
|||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = "opportunities-extension-products-ramp";
|
badge.className = "opportunities-extension-products-ramp";
|
||||||
badge.setAttribute("data-missing", String(values.includes("N/D")));
|
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.title = `Ramp Months: ${values.join(", ")}`;
|
||||||
badge.setAttribute("aria-label", badge.title);
|
badge.setAttribute("aria-label", badge.title);
|
||||||
return badge;
|
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) {
|
function createProductsCountButton(optyNumber, productsResult) {
|
||||||
const normalizedOptyNumber = String(optyNumber);
|
const normalizedOptyNumber = String(optyNumber);
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
@@ -44,8 +53,8 @@ function createProductsDetails({
|
|||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
||||||
} else {
|
} else {
|
||||||
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
|
expandProducts(normalizedOptyNumber);
|
||||||
requestGenerateAiSummary(normalizedOptyNumber);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
@@ -105,7 +114,8 @@ function createProductsDetails({
|
|||||||
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
||||||
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
|
{ 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() === ""
|
{ 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");
|
const head = document.createElement("thead");
|
||||||
@@ -123,7 +133,10 @@ function createProductsDetails({
|
|||||||
const productRow = document.createElement("tr");
|
const productRow = document.createElement("tr");
|
||||||
columns.forEach((column) => {
|
columns.forEach((column) => {
|
||||||
const productCell = document.createElement("td");
|
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);
|
productRow.append(productCell);
|
||||||
});
|
});
|
||||||
body.append(productRow);
|
body.append(productRow);
|
||||||
@@ -165,5 +178,5 @@ function createProductsDetails({
|
|||||||
return Number.isFinite(number) ? `${number}%` : "-";
|
return Number.isFinite(number) ? `${number}%` : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
return { expandProducts, createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/features/products/forecast-update.js
Normal file
47
src/features/products/forecast-update.js
Normal 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 };
|
||||||
|
}
|
||||||
216
src/features/products/ramp-projection-view.js
Normal file
216
src/features/products/ramp-projection-view.js
Normal 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 };
|
||||||
|
}
|
||||||
52
src/features/products/ramp-projection.css
Normal file
52
src/features/products/ramp-projection.css
Normal 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; }
|
||||||
|
}
|
||||||
63
src/features/products/ramp-projection.js
Normal file
63
src/features/products/ramp-projection.js
Normal 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 };
|
||||||
|
}
|
||||||
51
src/features/products/workloads-cache.js
Normal file
51
src/features/products/workloads-cache.js
Normal 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 };
|
||||||
|
}
|
||||||
78
src/features/products/workloads.js
Normal file
78
src/features/products/workloads.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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) {
|
function tabsRemove(tabId) {
|
||||||
if (runtimeApi.tabs.remove.length <= 1) {
|
if (runtimeApi.tabs.remove.length <= 1) {
|
||||||
return runtimeApi.tabs.remove(tabId);
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
function createPlatformContentMessages({
|
function createPlatformContentMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
requestCache,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { browser, chrome } = environment;
|
const { browser, chrome } = environment;
|
||||||
function sendRuntimeMessage(message) {
|
function sendRuntimeMessage(message) {
|
||||||
|
return requestCache ? requestCache.send(message, sendUncached) : sendUncached(message);
|
||||||
|
}
|
||||||
|
function sendUncached(message) {
|
||||||
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
|
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
|
||||||
|
|
||||||
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
|
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
|||||||
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
||||||
] },
|
] },
|
||||||
keywords: null,
|
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"],
|
"Owner.PersonFirstName", "Owner.PersonLastName", "Owner.PrimaryEmail.EmailAddress", "DoNotCallFlag", "DoNotEmailFlag"],
|
||||||
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
|
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
|
||||||
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c",
|
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"
|
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
|
||||||
};
|
};
|
||||||
const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url,
|
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 {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
|
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function createFusionOpportunities({
|
|||||||
url: OPPORTUNITIES_QUERY_URL,
|
url: OPPORTUNITIES_QUERY_URL,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: `Bearer ${maskToken(token)}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Origin: "https://eeho.fa.us2.oraclecloud.com",
|
Origin: "https://eeho.fa.us2.oraclecloud.com",
|
||||||
Preference: "transient"
|
Preference: "transient"
|
||||||
@@ -102,19 +102,21 @@ function createFusionOpportunities({
|
|||||||
op: "$and",
|
op: "$and",
|
||||||
criteria: [
|
criteria: [
|
||||||
{
|
{
|
||||||
|
op: "$and",
|
||||||
|
criteria: [{
|
||||||
op: "$or",
|
op: "$or",
|
||||||
criteria: Array.from(getOpportunityTypeValues(), (value) => ({
|
criteria: Array.from(getOpportunityTypeValues(), (value) => ({
|
||||||
op: "$eq",
|
op: "$eq",
|
||||||
attribute: "RecordSet",
|
attribute: "RecordSet",
|
||||||
value
|
value
|
||||||
}))
|
}))
|
||||||
|
}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
op: "$wi",
|
op: "$wi",
|
||||||
attribute: "EffectiveDate",
|
attribute: "EffectiveDate",
|
||||||
value1: dateRange.startCloseDate,
|
value1: new Date(dateRange.startCloseDate).toISOString().replace(/Z$/, "+0000"),
|
||||||
value2: dateRange.endCloseDate,
|
value2: new Date(dateRange.endCloseDate).toISOString().replace(/Z$/, "+0000")
|
||||||
dynamicDate: false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -150,9 +152,11 @@ function createFusionOpportunities({
|
|||||||
"OptyId",
|
"OptyId",
|
||||||
"CustomerAccount.PartyUniqueName",
|
"CustomerAccount.PartyUniqueName",
|
||||||
"CustomerAccount.PartyId",
|
"CustomerAccount.PartyId",
|
||||||
"CustomerAccount.PartyNumber"
|
"CustomerAccount.PartyNumber",
|
||||||
|
"CustomerAccount.Region_c",
|
||||||
|
"CustomerAccount.PrimaryAddress.CountryCode"
|
||||||
],
|
],
|
||||||
sort: [],
|
sort: null,
|
||||||
language: "en",
|
language: "en",
|
||||||
skipInValidFields: true,
|
skipInValidFields: true,
|
||||||
skipHiddenFromUIFields: true,
|
skipHiddenFromUIFields: true,
|
||||||
|
|||||||
181
src/services/fusion/sr-create.js
Normal file
181
src/services/fusion/sr-create.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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) {
|
async function requestInternalServiceRequest(message) {
|
||||||
const debugRequests = [];
|
const debugRequests = [];
|
||||||
try {
|
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 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"
|
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}`)}`;
|
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
|
||||||
|
const query = async () => {
|
||||||
const result = await fetchJson(url, { method: "GET", credentials: "include",
|
const result = await fetchJson(url, { method: "GET", credentials: "include",
|
||||||
headers: { Accept: "application/json", Authorization: "Session" }
|
headers: { Accept: "application/json", Authorization: "Session" }
|
||||||
}, "requestInternalServiceRequest", debugRequests);
|
}, "requestInternalServiceRequest", debugRequests);
|
||||||
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
|
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 items = result.items.flatMap(item => {
|
||||||
const children = item.ServiceRequest_SRToInternalSR_Tgt;
|
const children = item.ServiceRequest_SRToInternalSR_Tgt;
|
||||||
if (children == null) return [];
|
if (children == null) return [];
|
||||||
@@ -23,5 +169,60 @@ function createFusionSrDetails({ fetchJson }) {
|
|||||||
throw error;
|
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") };
|
||||||
}
|
}
|
||||||
|
|||||||
118
src/services/fusion/time-entries.js
Normal file
118
src/services/fusion/time-entries.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
function createHttpBackgroundJson({
|
function createHttpBackgroundJson({
|
||||||
previewDebugBody,
|
previewDebugBody,
|
||||||
DEBUG_BODY_LIMIT,
|
observeResponseHeaders = () => {},
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
@@ -17,6 +17,7 @@ function createHttpBackgroundJson({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
observeResponseHeaders(response.url || url, response.headers);
|
||||||
const responsePayload = await readResponsePayload(response);
|
const responsePayload = await readResponsePayload(response);
|
||||||
const responseBody = createDebugBody(responsePayload.text);
|
const responseBody = createDebugBody(responsePayload.text);
|
||||||
completeBackgroundDebugEntry(debugEntry, response, responseBody, "");
|
completeBackgroundDebugEntry(debugEntry, response, responseBody, "");
|
||||||
@@ -125,7 +126,7 @@ function createHttpBackgroundJson({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDebugBody(text) {
|
function createDebugBody(text) {
|
||||||
return previewDebugBody(text, DEBUG_BODY_LIMIT);
|
return previewDebugBody(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDebugHeaders(headers) {
|
function normalizeDebugHeaders(headers) {
|
||||||
|
|||||||
@@ -1,25 +1,86 @@
|
|||||||
function createSpaCurrentUser({ fetchJson }) {
|
function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) {
|
||||||
async function requestCurrentUser() {
|
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 = [];
|
const debugRequests = [];
|
||||||
let user = null;
|
let sessionTab = null;
|
||||||
|
let receivedValidJson = false;
|
||||||
try {
|
try {
|
||||||
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
|
// 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" }
|
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }
|
||||||
}, "requestCurrentUser", debugRequests);
|
}, "requestCurrentUser", debugRequests);
|
||||||
if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) {
|
const validText = value => typeof value === "string" && value.trim().length > 0;
|
||||||
throw new Error("requestCurrentUser returned invalid user roles.");
|
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;
|
||||||
}
|
}
|
||||||
user = { id: result.user.id, name: result.user.name, roles: result.user.roles };
|
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 };
|
return { ok: true, user, debugRequests };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
error.debugRequests = debugRequests;
|
error.debugRequests = debugRequests;
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
// The identity token is not needed by content scripts or debug, including on failures.
|
if (sessionTab) {
|
||||||
for (const entry of debugRequests) {
|
const entry = debugRequests[debugRequests.length - 1];
|
||||||
entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2);
|
try {
|
||||||
entry.responseBodyTruncated = false;
|
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"}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/services/spa/page-client.js
Normal file
34
src/services/spa/page-client.js
Normal 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 };
|
||||||
|
}
|
||||||
79
src/services/spa/workloads.js
Normal file
79
src/services/spa/workloads.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,40 +1,3 @@
|
|||||||
function createDebugBodyPreview(text, limit) {
|
function createDebugBodyPreview(text) {
|
||||||
if (text.length <= limit) return { value: text || "(empty response body)", truncated: false };
|
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||||
function createMessageTypes() {
|
function createMessageTypes() {
|
||||||
return Object.freeze({
|
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",
|
getXsrfToken: "opportunitiesExtension.getXsrfToken",
|
||||||
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
||||||
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
||||||
@@ -11,10 +19,21 @@ function createMessageTypes() {
|
|||||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||||
|
requestWorkloads: "opportunitiesExtension.requestWorkloads",
|
||||||
|
requestWorkloadForecastUpdate: "opportunitiesExtension.requestWorkloadForecastUpdate",
|
||||||
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
||||||
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
||||||
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
||||||
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
|
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",
|
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||||
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
|
|||||||
31
src/shared/time-dates.js
Normal file
31
src/shared/time-dates.js
Normal 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
22
src/vendor/fullcalendar/LICENSE.md
vendored
Normal 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
7
src/vendor/fullcalendar/NOTICE.md
vendored
Normal 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.
|
||||||
6
src/vendor/fullcalendar/index.global.min.js
vendored
Normal file
6
src/vendor/fullcalendar/index.global.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -44,6 +44,41 @@ function setup(family, saved = {}, principal = "user@example.invalid") {
|
|||||||
}
|
}
|
||||||
return { app, calls, load, saved, local };
|
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"]) {
|
for (const family of ["chrome", "firefox"]) {
|
||||||
test(`${family}: Current Quarter -> Next Quarter -> Current Quarter restores SR hours and filters without requests`, async () => {
|
test(`${family}: Current Quarter -> Next Quarter -> Current Quarter restores SR hours and filters without requests`, async () => {
|
||||||
const h = setup(family);
|
const h = setup(family);
|
||||||
|
|||||||
@@ -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[0].hidden, true, "hidden before identity resolves");
|
||||||
assert.equal(controls.children[1].hidden, true);
|
assert.equal(controls.children[1].hidden, true);
|
||||||
const identity = app.currentUser.ensure();
|
const identity = app.currentUser.ensure();
|
||||||
finish({ ok: true, user: { roles } });
|
finish({ ok: true, user: { id: "synthetic@example.invalid", name: "Synthetic User", roles } });
|
||||||
await identity;
|
await identity;
|
||||||
app.activityFilters.render();
|
app.activityFilters.render();
|
||||||
assert.equal(controls.children[0].hidden, !allowed);
|
assert.equal(controls.children[0].hidden, !allowed);
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ test("activity indicators share Products styles, fade after 5s, retain failures
|
|||||||
assert.equal(timers.size, 0);
|
assert.equal(timers.size, 0);
|
||||||
update("sr", "error");
|
update("sr", "error");
|
||||||
assert.equal(nodes[0].textContent, "Service Requests unavailable");
|
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 () => {
|
test("CRM reports real page counts and Actions reports queued and completed requests", async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const path = require("node:path");
|
|||||||
const vm = require("node:vm");
|
const vm = require("node:vm");
|
||||||
const { createHash } = require("node:crypto");
|
const { createHash } = require("node:crypto");
|
||||||
const { test } = require("node:test");
|
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 { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
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", () => {
|
test("every source belongs to an explicit runtime composition, with no duplicate factories", () => {
|
||||||
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
|
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
|
||||||
.filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort();
|
.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)) {
|
for (const [target, files] of Object.entries(sources)) {
|
||||||
assert.equal(new Set(files).size, files.length);
|
assert.equal(new Set(files).size, files.length);
|
||||||
const factories = files.flatMap(file => [...read(file).matchAll(/^function (\w+)\(/gm)].map(match => match[1]));
|
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", () => {
|
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", () => {
|
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();
|
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))), {
|
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"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ test("consistent inline vectors replace raster icons without parentheses or lost
|
|||||||
assert.equal(count.title, "3 products");
|
assert.equal(count.title, "3 products");
|
||||||
assert.equal(typeof count.listeners.click, "function");
|
assert.equal(typeof count.listeners.click, "function");
|
||||||
const ramp = app.productsDetails.createProductsRampBadge(products);
|
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");
|
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
|
||||||
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
|
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
|
||||||
checkIcon(missingRamp, "ramp", "N/D");
|
checkIcon(missingRamp, "ramp", "N/D");
|
||||||
@@ -70,7 +70,7 @@ test("consistent inline vectors replace raster icons without parentheses or lost
|
|||||||
checkIcon(sr, "sr", "4h");
|
checkIcon(sr, "sr", "4h");
|
||||||
assert.equal(sr.title, "Whether a Service Request has been created for the opportunities.");
|
assert.equal(sr.title, "Whether a Service Request has been created for the opportunities.");
|
||||||
assert.equal(typeof sr.listeners.click, "function");
|
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.productsContentState.opptyProductsByNumber.set("TEST", products);
|
||||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const FILTER_KEY = "opportunities-extension-filter-preferences";
|
|||||||
const TYPE_KEY = "opportunities-extension-opportunity-type-preferences";
|
const TYPE_KEY = "opportunities-extension-opportunity-type-preferences";
|
||||||
const CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
|
const CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
|
||||||
const DAY = 86400000;
|
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 drain = () => new Promise(resolve => setImmediate(resolve));
|
||||||
const productRequest = (requestId, optyNumbers = ["SYNTH-1"]) => ({
|
const productRequest = (requestId, optyNumbers = ["SYNTH-1"]) => ({
|
||||||
type: PREFIX + "requestOpptyProducts", requestId, optyNumbers, accessToken: "synthetic-products-token"
|
type: PREFIX + "requestOpptyProducts", requestId, optyNumbers, accessToken: "synthetic-products-token"
|
||||||
@@ -40,11 +40,13 @@ test("distribution contracts: manifests, execution boundaries and deterministic
|
|||||||
const base = {
|
const base = {
|
||||||
manifest_version: 3, name: "Opportunities Extension",
|
manifest_version: 3, name: "Opportunities Extension",
|
||||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
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/*"],
|
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"],
|
||||||
content_scripts: [{
|
content_scripts: [{
|
||||||
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
|
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"
|
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"]) {
|
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.equal(output, composeScript(file.replace(".js", "")));
|
||||||
assert.doesNotThrow(() => new vm.Script(output, { filename: `${target}/${file}` }));
|
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", () => {
|
test("fiscal dates preserve quarter/year boundaries, leap year and unsupported renewals", () => {
|
||||||
const h = loadContent();
|
const h = loadContent();
|
||||||
const cases = [
|
const cases = [
|
||||||
["Current Quarter", [2026, 5, 1], "2026-06-01", "2026-08-31"],
|
["Current Quarter", [2026, 0, 1], "2025-12-01", "2026-03-01"],
|
||||||
["Current Quarter", [2026, 7, 31], "2026-06-01", "2026-08-31"],
|
["Current Quarter", [2026, 1, 28], "2025-12-01", "2026-03-01"],
|
||||||
["Current Quarter", [2026, 8, 1], "2026-09-01", "2026-11-30"],
|
["Current Quarter", [2026, 2, 1], "2026-03-01", "2026-06-01"],
|
||||||
["Current Quarter", [2028, 1, 29], "2027-12-01", "2028-02-29"],
|
["Current Quarter", [2026, 4, 31], "2026-03-01", "2026-06-01"],
|
||||||
["Next Quarter", [2026, 10, 30], "2026-12-01", "2027-02-28"],
|
["Current Quarter", [2026, 5, 1], "2026-06-01", "2026-09-01"],
|
||||||
["Previous Quarter", [2026, 7, 24], "2026-03-01", "2026-05-31"],
|
["Current Quarter", [2026, 7, 31], "2026-06-01", "2026-09-01"],
|
||||||
["Current Fiscal Year", [2026, 4, 31], "2025-06-01", "2026-05-31"],
|
["Current Quarter", [2026, 8, 1], "2026-09-01", "2026-12-01"],
|
||||||
["Current Fiscal Year", [2026, 5, 1], "2026-06-01", "2027-05-31"],
|
["Current Quarter", [2026, 9, 19], "2026-09-01", "2026-12-01"],
|
||||||
["Current Fiscal Year", [2027, 0, 1], "2026-06-01", "2027-05-31"],
|
["Current Quarter", [2026, 10, 30], "2026-09-01", "2026-12-01"],
|
||||||
["4 Rolling Quarters (CQ + 3)", [2026, 8, 16], "2026-09-01", "2027-08-31"]
|
["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) {
|
for (const [period, date, start, end] of cases) {
|
||||||
assert.deepEqual(plain(h.getFiscalPeriodRange(period, new Date(...date))), {
|
assert.deepEqual(plain(h.getFiscalPeriodRange(period, new Date(...date))), {
|
||||||
startCloseDate: start + "T00:00:00.000Z", endCloseDate: end + "T00:00:00.000Z"
|
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);
|
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)), {
|
assert.deepEqual(plain(h.createOpportunitiesQueryPayload(range, 15)), {
|
||||||
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "Opportunity",
|
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "Opportunity",
|
||||||
q: { op: "$and", criteria: [
|
q: { op: "$and", criteria: [
|
||||||
{ op: "$or", criteria: types.map(value => ({ op: "$eq", attribute: "RecordSet", value })) },
|
{ 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: "$wi", attribute: "EffectiveDate", value1: "2026-06-01T00:00:00.000+0000", value2: "2026-09-01T00:00:00.000+0000" }
|
||||||
] }, keywords: null, keywordsFields: fields,
|
] }, keywords: null, keywordsFields: fields,
|
||||||
fields: [...fields, "CustomerAccount.PartyUniqueName", "CustomerAccount.PartyId", "CustomerAccount.PartyNumber"],
|
fields: [...fields, "CustomerAccount.PartyUniqueName", "CustomerAccount.PartyId", "CustomerAccount.PartyNumber", "CustomerAccount.Region_c", "CustomerAccount.PrimaryAddress.CountryCode"],
|
||||||
sort: [], language: "en", skipInValidFields: true, skipHiddenFromUIFields: true,
|
sort: null, language: "en", skipInValidFields: true, skipHiddenFromUIFields: true,
|
||||||
copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", limit: 15, offset: 15
|
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, "");
|
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", () => {
|
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"],
|
const storage = new Map([[FILTER_KEY, "not JSON"], ["opportunities-extension-theme", "dark"],
|
||||||
[TYPE_KEY, JSON.stringify({ opportunityTypeValues: ["ORA_MYOPTIES", "UNKNOWN"], opptyProductsCacheHours: 24 })]]);
|
[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(h.refreshes.length, 1);
|
||||||
assert.equal(calls.length, 1);
|
assert.equal(calls.length, 1);
|
||||||
assert.equal(calls[0].options.headers["x-xsrf-token"], "synthetic-xsrf");
|
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");
|
assert.equal(calls[0].options.method, "GET");
|
||||||
for (const status of [400, 401, 403]) {
|
for (const status of [400, 401, 403]) {
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ for (const family of ["chrome", "firefox"]) {
|
|||||||
assert.equal(h.tabs[0].active, false);
|
assert.equal(h.tabs[0].active, false);
|
||||||
assert.equal(h.tabs[0].windowId, 3);
|
assert.equal(h.tabs[0].windowId, 3);
|
||||||
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
|
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.deepEqual(h.removedTabs, [91]);
|
||||||
assert.equal(result.debugRequests[0].httpStatus, status);
|
assert.equal(result.debugRequests[0].httpStatus, status);
|
||||||
assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/);
|
assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/);
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ async function run() {
|
|||||||
assert.equal(createdTabs[0].active, false);
|
assert.equal(createdTabs[0].active, false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
createdTabs[0].url,
|
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.deepEqual(removedTabs, [27]);
|
||||||
assert.equal(response.debugRequests.length, 2);
|
assert.equal(response.debugRequests.length, 2);
|
||||||
|
|||||||
@@ -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 () => {
|
test("CRM fetch is called without the environment object as its receiver", async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const app = setup(async function (url, options) {
|
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: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
|
||||||
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
||||||
] }, keywords: null,
|
] }, 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"],
|
"Owner.PersonLastName", "Owner.PrimaryEmail.EmailAddress", "DoNotCallFlag", "DoNotEmailFlag"],
|
||||||
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
|
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
|
||||||
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"],
|
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.equal(log.status, "success");
|
||||||
assert.ok(log.responseBody);
|
assert.ok(log.responseBody);
|
||||||
assert.ok(log.durationMs >= 0);
|
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");
|
assert.equal(app.debugContentView.getRequestCategory(log), "serviceRequests");
|
||||||
}
|
}
|
||||||
const found = app.opportunityCrmServiceRequests.createBadge("TEST-1");
|
const found = app.opportunityCrmServiceRequests.createBadge("TEST-1");
|
||||||
|
|||||||
@@ -2,6 +2,292 @@ const assert = require("node:assert/strict");
|
|||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const { loadBackground, response } = require("./support/characterization.cjs");
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
const type = "opportunitiesExtension.requestInternalServiceRequest";
|
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"]) {
|
for (const family of ["chrome", "firefox"]) {
|
||||||
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
|
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
|||||||
@@ -3,7 +3,88 @@ const { test } = require("node:test");
|
|||||||
const { loadApplication } = require("./support/application.cjs");
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
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();
|
const h = setup();
|
||||||
let renewals = 0;
|
let renewals = 0;
|
||||||
const sent = [];
|
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.length, 1);
|
||||||
assert.equal(sent[0].token, undefined);
|
assert.equal(sent[0].token, undefined);
|
||||||
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
|
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() {
|
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.token, undefined);
|
||||||
assert.equal(calls[0].message.srId, "NOT-THE-URL-ID");
|
assert.equal(calls[0].message.srId, "NOT-THE-URL-ID");
|
||||||
assert.ok(h.walk(h.panel()).some(node => node.textContent === "Loading SR details..."));
|
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: [] });
|
calls[1].resolve({ ok: true, items: [] });
|
||||||
await flush();
|
await flush();
|
||||||
const nodes = h.walk(h.panel());
|
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.ok(link.href.endsWith("srNumber=SR%20%26child&srId=99"));
|
||||||
assert.equal(link.parent.children[2].title, "Copy SR number");
|
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("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."));
|
assert.ok(nodes.some(node => node.textContent === "No further details."));
|
||||||
h.app.crmSrPopover?.close();
|
h.app.crmSrPopover?.close();
|
||||||
h.app.opportunityCrmServiceRequests.reset();
|
h.app.opportunityCrmServiceRequests.reset();
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ const { test } = require("node:test");
|
|||||||
const { loadBackground, response } = require("./support/characterization.cjs");
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
const { loadApplication } = require("./support/application.cjs");
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
const type = "opportunitiesExtension.requestCurrentUser";
|
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"]) {
|
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 calls = [];
|
||||||
const app = loadBackground({ family, fetch: async (url, options) => {
|
const app = loadBackground({ family, fetch: async (url, options) => {
|
||||||
calls.push({ 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 });
|
const result = await app.dispatch({ type });
|
||||||
assert.equal(result.ok, true);
|
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(calls[0].options.cache, "no-store");
|
||||||
assert.equal(result.debugRequests[0].name, "requestCurrentUser");
|
assert.equal(result.debugRequests[0].name, "requestCurrentUser");
|
||||||
assert.equal(result.debugRequests[0].httpStatus, 200);
|
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(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 [
|
for (const fetch of [
|
||||||
async () => new Response("synthetic-secret<html>fallback</html>"),
|
async () => new Response("synthetic-secret<html>fallback</html>"),
|
||||||
async () => response({ token: "synthetic-secret", user: {} }),
|
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 });
|
const result = await app.dispatch({ type });
|
||||||
assert.equal(result.ok, false);
|
assert.equal(result.ok, false);
|
||||||
assert.equal(result.debugRequests[0].status, "failed");
|
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) {
|
test("retry timer is called without the environment object as its receiver", async () => {
|
||||||
const app = loadApplication("content", { document: { createElement: () => ({ setAttribute() {} }) } });
|
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 = () => {};
|
app.debugContentView.renderDebugPanel = () => {};
|
||||||
const calls = [];
|
const calls = [];
|
||||||
app.platformContentMessages.sendRuntimeMessage = async message => {
|
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);
|
assert.equal(app.currentUser.allowsCrmActivities(), false);
|
||||||
const same = app.currentUser.ensure();
|
const same = app.currentUser.ensure();
|
||||||
assert.equal(same, app.currentUser.ensure());
|
assert.equal(same, app.currentUser.ensure());
|
||||||
finish({ ok: true, user: { roles } });
|
finish({ ok: true, user: { ...validIdentity.user, roles } });
|
||||||
await first;
|
await first;
|
||||||
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]);
|
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]);
|
||||||
app.productsContentState.currentProductsRequestId = "products-cycle";
|
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"); }]) {
|
for (const identity of [async () => ({ ok: false }), async () => ({ ok: true, user: {} }), async () => { throw new Error("Offline"); }]) {
|
||||||
const { app, calls } = content(identity);
|
const timers = [];
|
||||||
await app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter");
|
const delays = [];
|
||||||
await app.opportunitiesController.requestOpportunitiesForPeriod("Next Quarter");
|
let attempts = 0;
|
||||||
assert.equal(calls.filter(value => value === type).length, 1);
|
const { app, calls } = content(() => ++attempts <= 6 ? identity() : { ok: true, user: validIdentity.user }, {
|
||||||
assert.equal(calls.filter(value => value === "opportunities").length, 2);
|
setTimeout: (callback, delay) => { timers.push(callback); delays.push(delay); }
|
||||||
assert.equal(calls.includes("crm"), false);
|
});
|
||||||
app.opportunityWorkloadActions.loadProducts({ items: [{ RevnId: "123" }] });
|
const first = app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter");
|
||||||
assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), false);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,26 @@ function fixture() {
|
|||||||
return { ...dom, app, add, nodes, tab, ids, select };
|
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", () => {
|
test("request summaries show duration after status, including zero, failure and pending", () => {
|
||||||
const h = fixture();
|
const h = fixture();
|
||||||
h.add("duration", "requestOpportunities", 1);
|
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 h = fixture();
|
||||||
const background = loadApplication("background");
|
const background = loadApplication("background");
|
||||||
const body = JSON.stringify({ items: Array.from({ length: 2000 }, (_, index) => ({
|
const body = JSON.stringify({ items: Array.from({ length: 2000 }, (_, index) => ({
|
||||||
id: index, name: "Synthetic opportunity", access_token: "synthetic-secret",
|
id: index, name: "Synthetic opportunity", access_token: "synthetic-secret",
|
||||||
uiaasHeader: { value: "synthetic-header-secret" }, owner: { name: "Synthetic owner" }
|
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);
|
const preview = logger.createDebugBody(body);
|
||||||
assert.equal(preview.truncated, true);
|
assert.equal(preview.truncated, false);
|
||||||
assert.ok(preview.value.length <= limit);
|
assert.equal(preview.value, body);
|
||||||
const parsed = JSON.parse(preview.value);
|
const parsed = JSON.parse(preview.value);
|
||||||
assert.match(parsed.debugNotice, /Partial response/);
|
assert.equal(parsed.items.length, 2000);
|
||||||
assert.ok(parsed.preview.items.length > 0);
|
assert.equal(parsed.items.at(-1).id, 1999);
|
||||||
assert.ok(parsed.preview.items.length < 2000);
|
|
||||||
const formatted = h.app.debugContentLog.formatDebugBody(preview.value);
|
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/);
|
assert.doesNotMatch(formatted, /synthetic-secret|synthetic-header-secret/);
|
||||||
const container = h.document.createElement("div");
|
const container = h.document.createElement("div");
|
||||||
h.app.debugContentView.appendDebugBlock(container, "Response Body", formatted);
|
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/);
|
assert.match(h.app.debugContentLog.formatDebugBody(preview.value), /synthetic-secret/);
|
||||||
h.app.debugState.debugSecretsVisible = false;
|
h.app.debugState.debugSecretsVisible = false;
|
||||||
assert.equal(logger.createDebugBody('{"ok":true}').value, '{"ok":true}');
|
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",
|
requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication",
|
||||||
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
|
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
|
||||||
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
|
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
|
||||||
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
|
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests", requestSRInfo: "serviceRequests",
|
||||||
requestConsumptionPlanDetail: "consumption",
|
requestConsumptionPlanDetail: "consumption",
|
||||||
requestRatecardByPlan: "consumption",
|
requestRatecardByPlan: "consumption",
|
||||||
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "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", () => {
|
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "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");
|
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
|
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
|
||||||
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));
|
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));
|
||||||
|
|||||||
41
tests/fixtures/extension.js
vendored
41
tests/fixtures/extension.js
vendored
@@ -1,18 +1,22 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const listeners = [];
|
const listeners = [];
|
||||||
const storage = new Map();
|
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) => ({
|
const opportunities = Array.from({ length: 25 }, (_, index) => ({
|
||||||
OptyNumber: `TEST${index + 1}`,
|
OptyNumber: `TEST${index + 1}`,
|
||||||
|
OptyId: 1000 + index,
|
||||||
Name: `Synthetic opportunity ${index + 1} - Database migration`,
|
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" },
|
Owner: { PersonFirstName: "Example", PersonLastName: "Owner" },
|
||||||
PrimaryRevenue: { RevnAmountCurcyCode: "USD", RevnAmount: "25000", WinProb: 60 },
|
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",
|
LastUpdateDate: "2026-09-15T17:00:00Z",
|
||||||
ForecastGroup_c: ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"][index % 5],
|
ForecastGroup_c: ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"][index % 5],
|
||||||
StatusCode: ["OPEN", "CLOSED", "LOST", "WON", "WON_PENDING"][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 }];
|
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) => ({
|
const trend = Array.from({ length: 30 }, (_, index) => ({
|
||||||
fyDate: `2026-09-${String((index % 28) + 1).padStart(2, "0")}`,
|
fyDate: `2026-09-${String((index % 28) + 1).padStart(2, "0")}`,
|
||||||
fiscalTime: [`${String((index % 28) + 1).padStart(2, "0")}-Sep-26`],
|
fiscalTime: [`${String((index % 28) + 1).padStart(2, "0")}-Sep-26`],
|
||||||
@@ -24,10 +28,40 @@
|
|||||||
accountSummary: { label: "Account-Ti-Authorization", value: "synthetic-accp" },
|
accountSummary: { label: "Account-Ti-Authorization", value: "synthetic-accp" },
|
||||||
accountPlans: { label: "G2m-Authorization", value: "synthetic-ocicons" }
|
accountPlans: { label: "G2m-Authorization", value: "synthetic-ocicons" }
|
||||||
};
|
};
|
||||||
|
if (projectionPreview) authorizations.workloadActions = { label: "Wwb-Provider-Authorization", value: "synthetic-wwb" };
|
||||||
const runtime = {
|
const runtime = {
|
||||||
onMessage: { addListener: listener => listeners.push(listener) },
|
onMessage: { addListener: listener => listeners.push(listener) },
|
||||||
async sendMessage(message) {
|
async sendMessage(message) {
|
||||||
const operation = message.type.split(".").at(-1);
|
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 === "getXsrfToken") return { ok: true, token: "synthetic-xsrf", cookieName: "XSRF-TOKEN-TEST" };
|
||||||
if (operation === "requestConsumerTokenService") return { ok: true, authorizations, debugRequests: [] };
|
if (operation === "requestConsumerTokenService") return { ok: true, authorizations, debugRequests: [] };
|
||||||
if (operation === "requestOpptyProducts") {
|
if (operation === "requestOpptyProducts") {
|
||||||
@@ -64,7 +98,8 @@
|
|||||||
let body;
|
let body;
|
||||||
if (url.pathname === "/fscmRestApi/tokenrelay") body = { access_token: "synthetic-fusion-token", expires_in: 3600 };
|
if (url.pathname === "/fscmRestApi/tokenrelay") body = { access_token: "synthetic-fusion-token", expires_in: 3600 };
|
||||||
else if (url.pathname.endsWith("custom-actions/queries")) {
|
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 };
|
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 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}`);
|
else throw new Error(`Unexpected synthetic fetch: ${url.pathname}`);
|
||||||
|
|||||||
107
tests/formRequestCache.test.cjs
Normal file
107
tests/formRequestCache.test.cjs
Normal 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"/);
|
||||||
|
});
|
||||||
145
tests/internalSrSession.test.cjs
Normal file
145
tests/internalSrSession.test.cjs
Normal 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
571
tests/manageTime.test.cjs
Normal 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);
|
||||||
|
});
|
||||||
227
tests/opportunityCalendar.test.cjs
Normal file
227
tests/opportunityCalendar.test.cjs
Normal 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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -84,7 +84,7 @@ test("ramp months preserve zero and flag missing values beside the product count
|
|||||||
assert.equal(badge(), undefined);
|
assert.equal(badge(), undefined);
|
||||||
for (const [values, expected] of [
|
for (const [values, expected] of [
|
||||||
[[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
|
[[], 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", {
|
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
|
||||||
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
|
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 {
|
else {
|
||||||
assert.equal(badge().textContent, expected);
|
assert.equal(badge().textContent, expected);
|
||||||
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D")));
|
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(badge().attributes["aria-label"], badge().title);
|
||||||
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
|
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");
|
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", () => {
|
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 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*\{([^}]+)\}/);
|
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, /td\[data-column="customer"\] \{\s*position: relative;/);
|
||||||
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
|
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
|
||||||
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "")
|
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.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, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
|
||||||
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
|
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 > 0);
|
||||||
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
|
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
|
||||||
}
|
}
|
||||||
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "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)};`));
|
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ function setup() {
|
|||||||
app.opportunityWorkloadActions.loadProducts = (products, refresh, options) => { if (!options?.cacheOnly) calls.push(products.items[0]?.RevnId || "empty products"); };
|
app.opportunityWorkloadActions.loadProducts = (products, refresh, options) => { if (!options?.cacheOnly) calls.push(products.items[0]?.RevnId || "empty products"); };
|
||||||
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true });
|
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true });
|
||||||
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
||||||
function start(token = "token") {
|
function start(token = "token", numbers = ["A"]) {
|
||||||
app.productsContentJobs.requestOpptyProductsInBackground([{ OptyNumber: "A" }], token);
|
app.productsContentJobs.requestOpptyProductsInBackground(numbers.map(OptyNumber => ({ OptyNumber })), token);
|
||||||
return app.productsContentState.currentProductsRequestId;
|
return app.productsContentState.currentProductsRequestId;
|
||||||
}
|
}
|
||||||
function progress(requestId, data) {
|
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);
|
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 h = setup();
|
||||||
const cached = h.start();
|
const cached = h.start();
|
||||||
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||||
h.progress(cached, { state: "complete" });
|
h.progress(cached, { state: "complete" });
|
||||||
await flush();
|
await flush();
|
||||||
assert.deepEqual(h.calls, []);
|
assert.deepEqual(h.calls, ["SR token", "cached"]);
|
||||||
const mixed = h.start();
|
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: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||||
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
|
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
|
||||||
h.progress(mixed, { state: "complete" });
|
h.progress(mixed, { state: "complete" });
|
||||||
await flush();
|
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 () => {
|
test("reopening an existing modal does not reload products or reset/query activities", async () => {
|
||||||
|
|||||||
209
tests/ramp-projection.test.cjs
Normal file
209
tests/ramp-projection.test.cjs
Normal 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
119
tests/spaPage.test.cjs
Normal 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);
|
||||||
|
});
|
||||||
87
tests/spaSessionReload.test.cjs
Normal file
87
tests/spaSessionReload.test.cjs
Normal 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);
|
||||||
|
});
|
||||||
181
tests/spaSessionWait.test.cjs
Normal file
181
tests/spaSessionWait.test.cjs
Normal 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
Reference in New Issue
Block a user