Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2ae057cc90
|
|||
|
8b4d88b226
|
|||
|
c9de750261
|
|||
|
7a7143b6e7
|
|||
|
e9c295be91
|
|||
|
f512ef9928
|
|||
|
3fd89a307c
|
|||
|
4d3f1f9760
|
|||
|
a182609415
|
|||
|
1a3decbeb6
|
|||
|
de4da0eec6
|
|||
|
cc567c8ff1
|
|||
|
6a3e9e6adf
|
|||
|
86973f7542
|
132
README.md
132
README.md
@@ -1,6 +1,25 @@
|
|||||||
# Opportunities Extension 1.0.2
|
# Opportunities Extension 1.0.3
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -19,40 +38,111 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
|
|||||||
|
|
||||||
## CRM Service Request Badges
|
## CRM Service Request Badges
|
||||||
|
|
||||||
|
The header's **Refresh all** button (between theme and Preferences) invalidates
|
||||||
|
persisted Products and activity caches and clears in-memory data views. It reloads
|
||||||
|
the selected Reference, fetching fresh products and then the permitted SR/Actions
|
||||||
|
queries. Customer details, rate cards, consumption charts and AI summaries are
|
||||||
|
requested again when opened. Preferences, cookies and authentication are preserved.
|
||||||
|
The button is disabled during invalidation and list reload; product/activity badges
|
||||||
|
continue to show subsequent background progress. Cache-clear failures are surfaced
|
||||||
|
instead of silently reusing cached data, and stale product responses cannot restore
|
||||||
|
an invalidated cache.
|
||||||
|
|
||||||
On the first Opportunities modal opening in a page session, `requestCurrentUser`
|
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;
|
If identity or roles cannot be validated, only these two features stay disabled;
|
||||||
other opportunity features continue. Reload the page to refresh identity/roles.
|
other opportunity features continue. Reload the page to refresh identity/roles.
|
||||||
The unused identity token is discarded and redacted from debug responses.
|
The identity token is retained in debug responses and hidden by default; the
|
||||||
|
explicit sensitive-values toggle reveals the original value.
|
||||||
|
|
||||||
Once per opening of the opportunities modal, `resquestCrmServiceRequest` fetches all pages of
|
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,
|
||||||
|
preserves the originating window and Firefox cookie store, and retries within
|
||||||
|
the same five-attempt limit. Debug entries retain the HTTP status and recovery
|
||||||
|
reason. The auxiliary tab is closed after valid JSON or exhausted retries.
|
||||||
|
|
||||||
|
After a completed product-loading cycle with fresh API results, `resquestCrmServiceRequest` fetches all pages of
|
||||||
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`,
|
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`,
|
||||||
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
|
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
|
||||||
query endpoint, independently of the opportunity query version. Calls appear in
|
query endpoint, independently of the opportunity query version. Calls appear in
|
||||||
the Service Requests debug category with masked credentials.
|
the Service Requests debug category with masked credentials.
|
||||||
|
After a complete successful SR query (HTTP 200 with valid responses on all pages),
|
||||||
|
its result is reused for subsequent Reference/product cycles in the current page
|
||||||
|
session. Refresh all explicitly clears it. Failed pages are retried up to five
|
||||||
|
total attempts each; completed pages are not requested again. Resetting the query
|
||||||
|
stops further retries from the previous run.
|
||||||
|
|
||||||
Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates
|
Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates
|
||||||
one or more matches; its tooltip lists each SR number, title, reported hours, owner,
|
one or more matches; its tooltip lists each SR number, title, reported hours, owner,
|
||||||
email and contact flags. A red badge indicates no match within that query's scope.
|
email and contact flags. A red badge indicates no match within that query's scope.
|
||||||
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
|
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
|
||||||
Badges follow Ramp Months and do not wait for product downloads. SR results remain
|
Badges follow Ramp Months. Successful SR and Actions responses are persisted in
|
||||||
in memory only. Reference changes share the in-flight lookup and reuse its result
|
extension local storage, isolated by tokenrelay principal. The cache uses the same
|
||||||
(including failures) without another request. Closing resets this state; reopening
|
Preferences lifetime as Products (24 hours by default), anchored to the product
|
||||||
starts a fresh lookup, and responses from previous openings are ignored.
|
timestamp rather than the activity response time. Actions entries match each
|
||||||
|
product's update; the SR snapshot matches the latest product update in the cycle.
|
||||||
|
SR snapshots are stored separately by update timestamp so changing Reference does
|
||||||
|
not overwrite a previous period's snapshot. Expired snapshots are removed on
|
||||||
|
subsequent SR cache writes; Refresh all invalidates every snapshot.
|
||||||
|
No credentials, request headers or debug envelopes are stored in this cache.
|
||||||
|
Products loaded from cache restore matching activity entries without network calls.
|
||||||
|
Missing, expired or mismatched entries are not treated as confirmed empty results;
|
||||||
|
they wait for a real product refresh before a new activity query is allowed.
|
||||||
|
Empty and entirely failed product cycles do not query activities. In mixed cycles,
|
||||||
|
only fresh products permit activity API calls on cache misses; an already-loaded
|
||||||
|
SR result is reused and associated with the new product update.
|
||||||
|
Activity lookups do not advance the existing Last update timestamp.
|
||||||
|
Closing and reopening the modal preserves results and does not
|
||||||
|
reload products or trigger these lookups. Already-running product requests may
|
||||||
|
finish in the background and update their related activities.
|
||||||
|
|
||||||
## Workload Actions
|
## Workload Actions
|
||||||
|
|
||||||
Workload Actions are queried in the background after product results arrive,
|
The missing Act badge includes a separate plus control that opens an Action form
|
||||||
including cached products. Each distinct `RevnId` uses `requestWlActions` with
|
with Owner, Role, Action, Team, Start date, End Date, Status, Complexity and Notes.
|
||||||
|
Opening the form loads team members and action lookups using the WWB-TS token.
|
||||||
|
Team members use the product RevnId; when multiple workloads are available, the
|
||||||
|
user selects one before its members are requested. Owner options use email for
|
||||||
|
both label and value; lookup options display name and submit code.
|
||||||
|
All Action selects support local autocomplete by label or code, arrow-key
|
||||||
|
navigation and Enter selection. Escape or leaving the field restores the last
|
||||||
|
selected option; typed search text is never submitted as an option value.
|
||||||
|
Fields are enabled only after both metadata requests return HTTP 200. Loading,
|
||||||
|
errors and manual retry are supported, and debug logs mask the credential.
|
||||||
|
Save validates all required fields and the date range before posting to the WWB
|
||||||
|
actions endpoint. Dates are sent at midnight UTC in ISO format; notes are an
|
||||||
|
array of strings (empty when omitted). The current user.id supplies createdBy
|
||||||
|
and updatedBy. While saving, editing and dismissal are locked to prevent duplicate
|
||||||
|
submissions. Success closes the form and displays a toast; failure preserves the
|
||||||
|
form and displays an error toast without automatically retrying the POST.
|
||||||
|
Cancel or Escape dismisses an idle form. The form is unavailable to SALES_REP
|
||||||
|
users, like the other Action features.
|
||||||
|
|
||||||
|
SR and Action filter badges appear before Status for users allowed to view these
|
||||||
|
activities. They filter the loaded data without issuing requests and combine with
|
||||||
|
the existing opportunity filters. The SR below 4h option replaces the other SR
|
||||||
|
selections and matches at least one linked SR with a numeric `ReportedHours_c < 4`
|
||||||
|
(including zero, excluding missing values). Action matches use the same ownership
|
||||||
|
rule as the row badge. Loading/failed lookups are not treated as missing records;
|
||||||
|
with both positive and negative options selected, all activity states remain
|
||||||
|
visible. These selections are kept in memory, not persisted.
|
||||||
|
|
||||||
|
Workload Actions are queried in the background after the product cycle completes,
|
||||||
|
and only after SR has completed successfully (or was restored/reused). If SR is
|
||||||
|
unavailable, Actions are not requested and remain unknown rather than absent.
|
||||||
|
Each failed workload lookup gets up to five total attempts, stopping on success
|
||||||
|
or when its session is superseded. Cached products still do not trigger network
|
||||||
|
lookups. Each distinct `RevnId` uses `requestWlActions` with
|
||||||
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
|
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
|
||||||
aggregates all products: green means an action's `owner` matches the general
|
aggregates all products: green means an action's `owner` matches the general
|
||||||
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
||||||
means ownership could not be confirmed (including a missing principal). Tooltips
|
means ownership could not be confirmed (including a missing principal). Tooltips
|
||||||
retain all returned actions, regardless of owner. Principal stays in memory only.
|
retain all returned actions, regardless of owner. Credentials stay in memory only;
|
||||||
Queries are shared per modal opening (up to four concurrently), reused
|
the principal is used to scope persisted activity entries.
|
||||||
across Reference changes, and discarded on close. Reopening queries again.
|
Queries are shared per product cycle (up to four concurrently). Reference changes
|
||||||
|
that load products from cache do not refresh activities.
|
||||||
Only the first page is requested for now; `hasNext` is indicated in the tooltip.
|
Only the first page is requested for now; `hasNext` is indicated in the tooltip.
|
||||||
The debug panel groups these requests under Actions, with credentials masked.
|
The debug panel groups these requests under Actions, with credentials masked.
|
||||||
|
|
||||||
@@ -179,8 +269,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.
|
||||||
|
|
||||||
@@ -246,6 +337,17 @@ 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.
|
||||||
|
|
||||||
## Loading in Development Mode
|
## Loading in Development Mode
|
||||||
|
|
||||||
### Chrome or Microsoft Edge
|
### Chrome or Microsoft Edge
|
||||||
|
|||||||
762
dist/chromium/background.js
vendored
762
dist/chromium/background.js
vendored
@@ -14,7 +14,21 @@ function createMessageTypes() {
|
|||||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||||
|
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
||||||
|
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
||||||
|
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
||||||
|
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
|
||||||
|
requestSrLookupPrimaryPillar: "opportunitiesExtension.requestSrLookupPrimaryPillar",
|
||||||
|
requestPostServiceRequest: "opportunitiesExtension.requestPostServiceRequest",
|
||||||
|
requestPostInternalServiceRequest: "opportunitiesExtension.requestPostInternalServiceRequest",
|
||||||
|
prepareSrSession: "opportunitiesExtension.prepareSrSession",
|
||||||
|
requestSrLookupLanguage: "opportunitiesExtension.requestSrLookupLanguage",
|
||||||
|
requestSrLookupTechPlatform: "opportunitiesExtension.requestSrLookupTechPlatform",
|
||||||
|
requestLookupTypePlatform: "opportunitiesExtension.requestLookupTypePlatform",
|
||||||
|
requestInternalSrServices: "opportunitiesExtension.requestInternalSrServices",
|
||||||
|
requestSrLookupOpportunityBusiness: "opportunitiesExtension.requestSrLookupOpportunityBusiness",
|
||||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||||
|
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||||
@@ -22,45 +36,8 @@ function createMessageTypes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/shared/debug-body.js
|
// Source: src/shared/debug-body.js
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/background/contracts.js
|
// Source: src/background/contracts.js
|
||||||
@@ -249,14 +226,22 @@ function createAuthConsumerSession({
|
|||||||
const isHtmlFallback = error
|
const isHtmlFallback = error
|
||||||
&& error.code === "INVALID_JSON_RESPONSE"
|
&& error.code === "INVALID_JSON_RESPONSE"
|
||||||
&& error.httpStatus === 200;
|
&& error.httpStatus === 200;
|
||||||
|
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
|
||||||
|
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
|
||||||
|
const retryReason = isSessionUnauthorized
|
||||||
|
? `HTTP ${error.httpStatus} response requires SPA session recovery`
|
||||||
|
: "HTTP 200 response did not contain valid JSON";
|
||||||
const debugEntry = debugRequests[debugRequests.length - 1];
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
if (isHtmlFallback) {
|
if (needsSessionRecovery) {
|
||||||
debugEntry.metadata.retryLimitReached = "true";
|
debugEntry.metadata.retryLimitReached = "true";
|
||||||
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
|
debugEntry.error = isHtmlFallback
|
||||||
|
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
|
||||||
|
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
||||||
error.message = debugEntry.error;
|
error.message = debugEntry.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +269,7 @@ function createAuthConsumerSession({
|
|||||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +397,117 @@ function createAuthSpaCredentials({
|
|||||||
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
|
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source: src/auth/sr-request-headers.js
|
||||||
|
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)\/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/*`], 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;
|
||||||
|
}
|
||||||
|
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders };
|
||||||
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/accounts.js
|
// Source: src/services/spa/accounts.js
|
||||||
function createSpaAccounts({
|
function createSpaAccounts({
|
||||||
ACCOUNT_PLANS_PAYLOAD,
|
ACCOUNT_PLANS_PAYLOAD,
|
||||||
@@ -644,6 +740,463 @@ function createSpaWorkloadActions({ fetchJson }) {
|
|||||||
return { requestWlActions };
|
return { requestWlActions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source: src/services/spa/action-options.js
|
||||||
|
function createSpaActionOptions({ fetchJson }) {
|
||||||
|
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||||
|
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
|
||||||
|
async function request(message, name, path, normalize) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await fetchJson(`${baseUrl}/${path()}`, {
|
||||||
|
method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
|
||||||
|
}, name, debugRequests);
|
||||||
|
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
|
||||||
|
return { ok: true, ...normalize(result), debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
const entry = debugRequests.at(-1);
|
||||||
|
if (entry) { entry.status = "failed"; entry.error = error.message; }
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requestActionTeamMembers(message) {
|
||||||
|
return request(message, "requestActionTeamMembers", () => {
|
||||||
|
const id = String(message.workloadId ?? "").trim();
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
|
||||||
|
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
|
||||||
|
}, result => {
|
||||||
|
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
|
||||||
|
throw new Error("requestActionTeamMembers returned an invalid collection.");
|
||||||
|
}
|
||||||
|
return { members: result.map(item => ({ email: item.email })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function requestActionFindLookup(message) {
|
||||||
|
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
|
||||||
|
const lookups = {};
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|
||||||
|
|| typeof item?.name !== "string" || !item.name.trim())) {
|
||||||
|
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
|
||||||
|
}
|
||||||
|
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
|
||||||
|
}
|
||||||
|
return { lookups };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function requestMgmtAction(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
const payload = message.payload;
|
||||||
|
const action = payload?.action;
|
||||||
|
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
|
||||||
|
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
|
||||||
|
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
|
||||||
|
}
|
||||||
|
for (const key of ["startDate", "endDate"]) {
|
||||||
|
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
|
||||||
|
}
|
||||||
|
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
|
||||||
|
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
|
||||||
|
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source: src/services/fusion/sr-details.js
|
||||||
|
function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFirefoxCookieStoreTabs, cookiesGetAll, cookieChanges, wait }) {
|
||||||
|
const sessions = new Map();
|
||||||
|
function cookiePart(cookie) {
|
||||||
|
if (!cookie) return null;
|
||||||
|
const match = /^ORA_OCIS_CG_SESSION_idcs-([a-zA-Z0-9-]+)_comcipapic-oalprod\.integration\.ocp\.oraclecloud\.com_([12])$/.exec(cookie.name);
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const cookieDomain = cookie.domain?.replace(/^\./, "");
|
||||||
|
if (!match || (cookieDomain && cookieDomain !== domain && !domain.endsWith(`.${cookieDomain}`))) return null;
|
||||||
|
return { key: `${match[1]}|${cookie.path || "/"}|${cookie.storeId || ""}`, part: match[2],
|
||||||
|
valid: Boolean(cookie.value) && (cookie.expirationDate == null || cookie.expirationDate > Date.now() / 1000) };
|
||||||
|
}
|
||||||
|
function watchSession(session, sender) {
|
||||||
|
const pairs = new Map();
|
||||||
|
let available;
|
||||||
|
session.cookieReady = new Promise(resolve => { available = resolve; });
|
||||||
|
const listener = change => {
|
||||||
|
const cookie = change.cookie;
|
||||||
|
const part = cookiePart(cookie);
|
||||||
|
if (!part) return;
|
||||||
|
if (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId) return;
|
||||||
|
if (!pairs.has(part.key)) pairs.set(part.key, new Map());
|
||||||
|
const pair = pairs.get(part.key);
|
||||||
|
if (change.removed || !part.valid) pair.delete(part.part);
|
||||||
|
else pair.set(part.part, cookie);
|
||||||
|
if (pair.size === 2 && [...pair.values()].every(item => cookiePart(item)?.valid)) available(new Set([part.key]));
|
||||||
|
session.wake?.();
|
||||||
|
};
|
||||||
|
cookieChanges?.addListener(listener);
|
||||||
|
session.unwatch = () => cookieChanges?.removeListener(listener);
|
||||||
|
}
|
||||||
|
function waitForSessionCookies(session, sender) {
|
||||||
|
if (session.cookieWait) return session.cookieWait;
|
||||||
|
session.cookieWait = (async () => {
|
||||||
|
const deadline = Date.now() + 1000;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
// Install the wake-up before reading so a renewal during getAll is not missed.
|
||||||
|
const changed = new Promise(resolve => { session.wake = resolve; });
|
||||||
|
const current = await sessionCookies(sender);
|
||||||
|
const renewed = [...current].some(pair => !session.baseline.has(pair));
|
||||||
|
if (renewed || Date.now() >= deadline) {
|
||||||
|
session.baseline = current;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
await Promise.race([changed, wait(250)]);
|
||||||
|
}
|
||||||
|
return sessionCookies(sender);
|
||||||
|
})().finally(() => { session.cookieWait = null; session.wake = null; });
|
||||||
|
return session.cookieWait;
|
||||||
|
}
|
||||||
|
async function sessionCookies(sender) {
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const details = { domain };
|
||||||
|
if (sender?.tab?.cookieStoreId) details.storeId = sender.tab.cookieStoreId;
|
||||||
|
try {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const cookie of await cookiesGetAll(details)) {
|
||||||
|
const part = cookiePart(cookie);
|
||||||
|
if (!part?.valid) continue;
|
||||||
|
if (!groups.has(part.key)) groups.set(part.key, {});
|
||||||
|
groups.get(part.key)[part.part] = [cookie.name, cookie.value, cookie.expirationDate ?? null];
|
||||||
|
}
|
||||||
|
// Fingerprints remain in memory only; the browser sends the actual Cookie header.
|
||||||
|
return new Set([...groups.values()].filter(pair => pair[1] && pair[2]).map(pair => JSON.stringify([pair[1], pair[2]])));
|
||||||
|
} catch { return new Set(); }
|
||||||
|
}
|
||||||
|
async function requestLookupWithSession(operation, message, sender, initialize = false) {
|
||||||
|
const existingCookies = initialize ? await sessionCookies(sender) : null;
|
||||||
|
if (existingCookies?.size) return { ok: true, debugRequests: [] };
|
||||||
|
const logs = [];
|
||||||
|
const query = async () => {
|
||||||
|
try {
|
||||||
|
const result = await operation(message);
|
||||||
|
logs.push(...(result.debugRequests || []));
|
||||||
|
return { ...result, debugRequests: logs };
|
||||||
|
} catch (error) {
|
||||||
|
logs.push(...(error.debugRequests || []));
|
||||||
|
error.debugRequests = logs;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!initialize) {
|
||||||
|
try { return await query(); } catch (error) { if (error.httpStatus !== 401) throw error; }
|
||||||
|
}
|
||||||
|
const key = sender?.tab?.cookieStoreId || "default";
|
||||||
|
let session = sessions.get(key);
|
||||||
|
if (!session) {
|
||||||
|
const details = {
|
||||||
|
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
|
||||||
|
active: false
|
||||||
|
};
|
||||||
|
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
|
||||||
|
if (supportsFirefoxCookieStoreTabs() && sender?.tab?.cookieStoreId) details.cookieStoreId = sender.tab.cookieStoreId;
|
||||||
|
session = { users: 0, closed: null, baseline: null };
|
||||||
|
watchSession(session, sender);
|
||||||
|
session.tab = (async () => {
|
||||||
|
session.baseline = existingCookies || await sessionCookies(sender);
|
||||||
|
return tabsCreate(details);
|
||||||
|
})();
|
||||||
|
sessions.set(key, session);
|
||||||
|
}
|
||||||
|
session.users += 1;
|
||||||
|
let tab;
|
||||||
|
try {
|
||||||
|
tab = await session.tab;
|
||||||
|
const deadline = Date.now() + 60000;
|
||||||
|
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
|
||||||
|
const pendingCookies = initialize && attempt === 0
|
||||||
|
? sessionCookies(sender) : waitForSessionCookies(session, sender);
|
||||||
|
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
|
||||||
|
if (initialize) {
|
||||||
|
// Proactive preparation observes cookies only; API requests belong to the form.
|
||||||
|
if (!cookies.size) continue;
|
||||||
|
await closeSessionTab();
|
||||||
|
return { ok: true, debugRequests: [] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await query();
|
||||||
|
// Any successful lookup confirms the shared session; stop the page immediately.
|
||||||
|
await closeSessionTab();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.httpStatus !== 401) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = logs;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
session.users -= 1;
|
||||||
|
if (session.users === 0) {
|
||||||
|
sessions.delete(key);
|
||||||
|
session.unwatch();
|
||||||
|
await closeSessionTab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeSessionTab() {
|
||||||
|
if (!session.closed && Number.isInteger(tab?.id)) {
|
||||||
|
session.closed = tabsRemove(tab.id).catch(() => {});
|
||||||
|
}
|
||||||
|
return session.closed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function requestInternalServiceRequest(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const id = String(message.srId ?? "");
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric SrId is required.");
|
||||||
|
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
|
||||||
|
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
|
||||||
|
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
|
||||||
|
const query = async () => {
|
||||||
|
const result = await fetchJson(url, { method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", Authorization: "Session" }
|
||||||
|
}, "requestInternalServiceRequest", debugRequests);
|
||||||
|
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const result = await query();
|
||||||
|
const items = result.items.flatMap(item => {
|
||||||
|
const children = item.ServiceRequest_SRToInternalSR_Tgt;
|
||||||
|
if (children == null) return [];
|
||||||
|
if (!Array.isArray(children) || children.some(child => !child || typeof child !== "object")) throw new Error("Invalid linked SR collection.");
|
||||||
|
return children;
|
||||||
|
});
|
||||||
|
return { ok: true, items, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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") };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source: src/services/fusion/sr-create.js
|
||||||
|
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders }) {
|
||||||
|
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(email, debugRequests) {
|
||||||
|
const emailAddress = text(email, "current user email");
|
||||||
|
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
|
||||||
|
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
|
||||||
|
}, "requestResourceUser", debugRequests);
|
||||||
|
return numericId(resource?.resourcePartyId, "resourcePartyId");
|
||||||
|
}
|
||||||
|
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.emailAddress, 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.emailAddress, 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestPostServiceRequest, requestPostInternalServiceRequest };
|
||||||
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/current-user.js
|
// Source: src/services/spa/current-user.js
|
||||||
function createSpaCurrentUser({ fetchJson }) {
|
function createSpaCurrentUser({ fetchJson }) {
|
||||||
async function requestCurrentUser() {
|
async function requestCurrentUser() {
|
||||||
@@ -662,12 +1215,6 @@ function createSpaCurrentUser({ fetchJson }) {
|
|||||||
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
|
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
|
||||||
error.debugRequests = debugRequests;
|
error.debugRequests = debugRequests;
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
// The identity token is not needed by content scripts or debug, including on failures.
|
|
||||||
for (const entry of debugRequests) {
|
|
||||||
entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2);
|
|
||||||
entry.responseBodyTruncated = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { requestCurrentUser };
|
return { requestCurrentUser };
|
||||||
@@ -848,11 +1395,11 @@ function createSpaResponseShapes() {
|
|||||||
// Source: src/services/http/background-json.js
|
// Source: src/services/http/background-json.js
|
||||||
function createHttpBackgroundJson({
|
function createHttpBackgroundJson({
|
||||||
previewDebugBody,
|
previewDebugBody,
|
||||||
DEBUG_BODY_LIMIT,
|
observeResponseHeaders = () => {},
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
async function fetchJson(url, options, requestName, debugRequests = null) {
|
async function fetchJson(url, options, requestName, debugRequests = null, { allowEmpty = false } = {}) {
|
||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
||||||
let response;
|
let response;
|
||||||
@@ -865,6 +1412,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, "");
|
||||||
@@ -873,7 +1421,10 @@ function createHttpBackgroundJson({
|
|||||||
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${requestName} failed with status ${response.status}.`);
|
const error = new Error(`${requestName} failed with status ${response.status}.`);
|
||||||
|
error.httpStatus = response.status;
|
||||||
|
debugEntry.error = error.message;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responsePayload.error) {
|
if (responsePayload.error) {
|
||||||
@@ -881,6 +1432,7 @@ function createHttpBackgroundJson({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (allowEmpty && !responsePayload.text.trim()) return null;
|
||||||
return parseJsonResponse(responsePayload.text, requestName);
|
return parseJsonResponse(responsePayload.text, requestName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
error.httpStatus = response.status;
|
error.httpStatus = response.status;
|
||||||
@@ -969,7 +1521,7 @@ function createHttpBackgroundJson({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDebugBody(text) {
|
function createDebugBody(text) {
|
||||||
return previewDebugBody(text, DEBUG_BODY_LIMIT);
|
return previewDebugBody(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDebugHeaders(headers) {
|
function normalizeDebugHeaders(headers) {
|
||||||
@@ -1134,8 +1686,17 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
storageLocalSet
|
||||||
}) {
|
}) {
|
||||||
|
let generation = 0;
|
||||||
|
async function invalidate() {
|
||||||
|
generation += 1;
|
||||||
|
productsCacheState.inFlightProducts.clear();
|
||||||
|
productsCacheState.productsCachePromise = Promise.resolve({});
|
||||||
|
await saveProductsCache({});
|
||||||
|
}
|
||||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
|
const currentGeneration = generation;
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
|
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
@@ -1153,15 +1714,14 @@ function createProductsCache({
|
|||||||
})
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
if (generation === currentGeneration) {
|
||||||
fetchedAt,
|
cache[optyNumber] = { fetchedAt, items };
|
||||||
items
|
|
||||||
};
|
|
||||||
await saveProductsCache(cache).catch(() => {});
|
await saveProductsCache(cache).catch(() => {});
|
||||||
|
}
|
||||||
return { items, fetchedAt };
|
return { items, fetchedAt };
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1207,7 +1767,7 @@ function createProductsCache({
|
|||||||
return productsCacheState.productsCacheWritePromise;
|
return productsCacheState.productsCacheWritePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/fusion/products.js
|
// Source: src/services/fusion/products.js
|
||||||
@@ -1720,12 +2280,27 @@ function startBackgroundApplication(dependencies) {
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
invalidateProductsCache,
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
requestInstallBase,
|
requestInstallBase,
|
||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestWlActions,
|
requestWlActions,
|
||||||
|
requestActionTeamMembers,
|
||||||
|
requestActionFindLookup,
|
||||||
|
requestMgmtAction,
|
||||||
|
requestPostServiceRequest,
|
||||||
|
requestPostInternalServiceRequest,
|
||||||
|
requestInternalServiceRequest,
|
||||||
|
requestSrLookupPrimaryPillar,
|
||||||
|
requestSrLookupWithSession,
|
||||||
|
prepareSrSession,
|
||||||
|
requestSrLookupLanguage,
|
||||||
|
requestSrLookupTechPlatform,
|
||||||
|
requestLookupTypePlatform,
|
||||||
|
requestInternalSrServices,
|
||||||
|
requestSrLookupOpportunityBusiness,
|
||||||
requestCurrentUser,
|
requestCurrentUser,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
@@ -1738,7 +2313,39 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (message.type === messageTypes.requestPostInternalServiceRequest) {
|
||||||
|
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestPostServiceRequest) {
|
||||||
|
requestPostServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.prepareSrSession) {
|
||||||
|
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const srLookups = {
|
||||||
|
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
|
||||||
|
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
|
||||||
|
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
|
||||||
|
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
|
||||||
|
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
|
||||||
|
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
|
||||||
|
};
|
||||||
|
if (Object.hasOwn(srLookups, message.type)) {
|
||||||
|
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.invalidateProductsCache) {
|
||||||
|
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (message.type === messageTypes.getXsrfToken) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
getXsrfTokenCookie()
|
getXsrfTokenCookie()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -1816,6 +2423,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
|
||||||
|
request(message, sender).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (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.",
|
||||||
@@ -1947,8 +2563,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({
|
||||||
@@ -2006,6 +2622,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
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 requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||||
|
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
|
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||||
|
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
|
||||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
@@ -2013,8 +2632,29 @@ 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 srRequestHeaders = createSrRequestHeaders({ runtimeApi });
|
||||||
|
const fusionSrCreate = createFusionSrCreate({
|
||||||
|
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, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
srRequestHeaders.start();
|
||||||
|
startBackgroundApplication({
|
||||||
|
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,
|
||||||
|
|||||||
2551
dist/chromium/content.js
vendored
2551
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
9
dist/chromium/manifest.json
vendored
9
dist/chromium/manifest.json
vendored
@@ -2,14 +2,17 @@
|
|||||||
"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.3",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
|
|||||||
762
dist/firefox/background.js
vendored
762
dist/firefox/background.js
vendored
@@ -14,7 +14,21 @@ function createMessageTypes() {
|
|||||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||||
|
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
||||||
|
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
||||||
|
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
||||||
|
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
|
||||||
|
requestSrLookupPrimaryPillar: "opportunitiesExtension.requestSrLookupPrimaryPillar",
|
||||||
|
requestPostServiceRequest: "opportunitiesExtension.requestPostServiceRequest",
|
||||||
|
requestPostInternalServiceRequest: "opportunitiesExtension.requestPostInternalServiceRequest",
|
||||||
|
prepareSrSession: "opportunitiesExtension.prepareSrSession",
|
||||||
|
requestSrLookupLanguage: "opportunitiesExtension.requestSrLookupLanguage",
|
||||||
|
requestSrLookupTechPlatform: "opportunitiesExtension.requestSrLookupTechPlatform",
|
||||||
|
requestLookupTypePlatform: "opportunitiesExtension.requestLookupTypePlatform",
|
||||||
|
requestInternalSrServices: "opportunitiesExtension.requestInternalSrServices",
|
||||||
|
requestSrLookupOpportunityBusiness: "opportunitiesExtension.requestSrLookupOpportunityBusiness",
|
||||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||||
|
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||||
@@ -22,45 +36,8 @@ function createMessageTypes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/shared/debug-body.js
|
// Source: src/shared/debug-body.js
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/background/contracts.js
|
// Source: src/background/contracts.js
|
||||||
@@ -249,14 +226,22 @@ function createAuthConsumerSession({
|
|||||||
const isHtmlFallback = error
|
const isHtmlFallback = error
|
||||||
&& error.code === "INVALID_JSON_RESPONSE"
|
&& error.code === "INVALID_JSON_RESPONSE"
|
||||||
&& error.httpStatus === 200;
|
&& error.httpStatus === 200;
|
||||||
|
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
|
||||||
|
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
|
||||||
|
const retryReason = isSessionUnauthorized
|
||||||
|
? `HTTP ${error.httpStatus} response requires SPA session recovery`
|
||||||
|
: "HTTP 200 response did not contain valid JSON";
|
||||||
const debugEntry = debugRequests[debugRequests.length - 1];
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
if (isHtmlFallback) {
|
if (needsSessionRecovery) {
|
||||||
debugEntry.metadata.retryLimitReached = "true";
|
debugEntry.metadata.retryLimitReached = "true";
|
||||||
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
|
debugEntry.error = isHtmlFallback
|
||||||
|
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
|
||||||
|
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
||||||
error.message = debugEntry.error;
|
error.message = debugEntry.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +269,7 @@ function createAuthConsumerSession({
|
|||||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +397,117 @@ function createAuthSpaCredentials({
|
|||||||
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
|
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source: src/auth/sr-request-headers.js
|
||||||
|
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)\/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/*`], 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;
|
||||||
|
}
|
||||||
|
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders };
|
||||||
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/accounts.js
|
// Source: src/services/spa/accounts.js
|
||||||
function createSpaAccounts({
|
function createSpaAccounts({
|
||||||
ACCOUNT_PLANS_PAYLOAD,
|
ACCOUNT_PLANS_PAYLOAD,
|
||||||
@@ -644,6 +740,463 @@ function createSpaWorkloadActions({ fetchJson }) {
|
|||||||
return { requestWlActions };
|
return { requestWlActions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source: src/services/spa/action-options.js
|
||||||
|
function createSpaActionOptions({ fetchJson }) {
|
||||||
|
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||||
|
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
|
||||||
|
async function request(message, name, path, normalize) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await fetchJson(`${baseUrl}/${path()}`, {
|
||||||
|
method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
|
||||||
|
}, name, debugRequests);
|
||||||
|
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
|
||||||
|
return { ok: true, ...normalize(result), debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
const entry = debugRequests.at(-1);
|
||||||
|
if (entry) { entry.status = "failed"; entry.error = error.message; }
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requestActionTeamMembers(message) {
|
||||||
|
return request(message, "requestActionTeamMembers", () => {
|
||||||
|
const id = String(message.workloadId ?? "").trim();
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
|
||||||
|
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
|
||||||
|
}, result => {
|
||||||
|
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
|
||||||
|
throw new Error("requestActionTeamMembers returned an invalid collection.");
|
||||||
|
}
|
||||||
|
return { members: result.map(item => ({ email: item.email })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function requestActionFindLookup(message) {
|
||||||
|
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
|
||||||
|
const lookups = {};
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|
||||||
|
|| typeof item?.name !== "string" || !item.name.trim())) {
|
||||||
|
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
|
||||||
|
}
|
||||||
|
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
|
||||||
|
}
|
||||||
|
return { lookups };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function requestMgmtAction(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
const payload = message.payload;
|
||||||
|
const action = payload?.action;
|
||||||
|
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
|
||||||
|
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
|
||||||
|
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
|
||||||
|
}
|
||||||
|
for (const key of ["startDate", "endDate"]) {
|
||||||
|
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
|
||||||
|
}
|
||||||
|
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
|
||||||
|
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
|
||||||
|
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source: src/services/fusion/sr-details.js
|
||||||
|
function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFirefoxCookieStoreTabs, cookiesGetAll, cookieChanges, wait }) {
|
||||||
|
const sessions = new Map();
|
||||||
|
function cookiePart(cookie) {
|
||||||
|
if (!cookie) return null;
|
||||||
|
const match = /^ORA_OCIS_CG_SESSION_idcs-([a-zA-Z0-9-]+)_comcipapic-oalprod\.integration\.ocp\.oraclecloud\.com_([12])$/.exec(cookie.name);
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const cookieDomain = cookie.domain?.replace(/^\./, "");
|
||||||
|
if (!match || (cookieDomain && cookieDomain !== domain && !domain.endsWith(`.${cookieDomain}`))) return null;
|
||||||
|
return { key: `${match[1]}|${cookie.path || "/"}|${cookie.storeId || ""}`, part: match[2],
|
||||||
|
valid: Boolean(cookie.value) && (cookie.expirationDate == null || cookie.expirationDate > Date.now() / 1000) };
|
||||||
|
}
|
||||||
|
function watchSession(session, sender) {
|
||||||
|
const pairs = new Map();
|
||||||
|
let available;
|
||||||
|
session.cookieReady = new Promise(resolve => { available = resolve; });
|
||||||
|
const listener = change => {
|
||||||
|
const cookie = change.cookie;
|
||||||
|
const part = cookiePart(cookie);
|
||||||
|
if (!part) return;
|
||||||
|
if (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId) return;
|
||||||
|
if (!pairs.has(part.key)) pairs.set(part.key, new Map());
|
||||||
|
const pair = pairs.get(part.key);
|
||||||
|
if (change.removed || !part.valid) pair.delete(part.part);
|
||||||
|
else pair.set(part.part, cookie);
|
||||||
|
if (pair.size === 2 && [...pair.values()].every(item => cookiePart(item)?.valid)) available(new Set([part.key]));
|
||||||
|
session.wake?.();
|
||||||
|
};
|
||||||
|
cookieChanges?.addListener(listener);
|
||||||
|
session.unwatch = () => cookieChanges?.removeListener(listener);
|
||||||
|
}
|
||||||
|
function waitForSessionCookies(session, sender) {
|
||||||
|
if (session.cookieWait) return session.cookieWait;
|
||||||
|
session.cookieWait = (async () => {
|
||||||
|
const deadline = Date.now() + 1000;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
// Install the wake-up before reading so a renewal during getAll is not missed.
|
||||||
|
const changed = new Promise(resolve => { session.wake = resolve; });
|
||||||
|
const current = await sessionCookies(sender);
|
||||||
|
const renewed = [...current].some(pair => !session.baseline.has(pair));
|
||||||
|
if (renewed || Date.now() >= deadline) {
|
||||||
|
session.baseline = current;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
await Promise.race([changed, wait(250)]);
|
||||||
|
}
|
||||||
|
return sessionCookies(sender);
|
||||||
|
})().finally(() => { session.cookieWait = null; session.wake = null; });
|
||||||
|
return session.cookieWait;
|
||||||
|
}
|
||||||
|
async function sessionCookies(sender) {
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const details = { domain };
|
||||||
|
if (sender?.tab?.cookieStoreId) details.storeId = sender.tab.cookieStoreId;
|
||||||
|
try {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const cookie of await cookiesGetAll(details)) {
|
||||||
|
const part = cookiePart(cookie);
|
||||||
|
if (!part?.valid) continue;
|
||||||
|
if (!groups.has(part.key)) groups.set(part.key, {});
|
||||||
|
groups.get(part.key)[part.part] = [cookie.name, cookie.value, cookie.expirationDate ?? null];
|
||||||
|
}
|
||||||
|
// Fingerprints remain in memory only; the browser sends the actual Cookie header.
|
||||||
|
return new Set([...groups.values()].filter(pair => pair[1] && pair[2]).map(pair => JSON.stringify([pair[1], pair[2]])));
|
||||||
|
} catch { return new Set(); }
|
||||||
|
}
|
||||||
|
async function requestLookupWithSession(operation, message, sender, initialize = false) {
|
||||||
|
const existingCookies = initialize ? await sessionCookies(sender) : null;
|
||||||
|
if (existingCookies?.size) return { ok: true, debugRequests: [] };
|
||||||
|
const logs = [];
|
||||||
|
const query = async () => {
|
||||||
|
try {
|
||||||
|
const result = await operation(message);
|
||||||
|
logs.push(...(result.debugRequests || []));
|
||||||
|
return { ...result, debugRequests: logs };
|
||||||
|
} catch (error) {
|
||||||
|
logs.push(...(error.debugRequests || []));
|
||||||
|
error.debugRequests = logs;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!initialize) {
|
||||||
|
try { return await query(); } catch (error) { if (error.httpStatus !== 401) throw error; }
|
||||||
|
}
|
||||||
|
const key = sender?.tab?.cookieStoreId || "default";
|
||||||
|
let session = sessions.get(key);
|
||||||
|
if (!session) {
|
||||||
|
const details = {
|
||||||
|
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
|
||||||
|
active: false
|
||||||
|
};
|
||||||
|
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
|
||||||
|
if (supportsFirefoxCookieStoreTabs() && sender?.tab?.cookieStoreId) details.cookieStoreId = sender.tab.cookieStoreId;
|
||||||
|
session = { users: 0, closed: null, baseline: null };
|
||||||
|
watchSession(session, sender);
|
||||||
|
session.tab = (async () => {
|
||||||
|
session.baseline = existingCookies || await sessionCookies(sender);
|
||||||
|
return tabsCreate(details);
|
||||||
|
})();
|
||||||
|
sessions.set(key, session);
|
||||||
|
}
|
||||||
|
session.users += 1;
|
||||||
|
let tab;
|
||||||
|
try {
|
||||||
|
tab = await session.tab;
|
||||||
|
const deadline = Date.now() + 60000;
|
||||||
|
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
|
||||||
|
const pendingCookies = initialize && attempt === 0
|
||||||
|
? sessionCookies(sender) : waitForSessionCookies(session, sender);
|
||||||
|
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
|
||||||
|
if (initialize) {
|
||||||
|
// Proactive preparation observes cookies only; API requests belong to the form.
|
||||||
|
if (!cookies.size) continue;
|
||||||
|
await closeSessionTab();
|
||||||
|
return { ok: true, debugRequests: [] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await query();
|
||||||
|
// Any successful lookup confirms the shared session; stop the page immediately.
|
||||||
|
await closeSessionTab();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.httpStatus !== 401) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = logs;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
session.users -= 1;
|
||||||
|
if (session.users === 0) {
|
||||||
|
sessions.delete(key);
|
||||||
|
session.unwatch();
|
||||||
|
await closeSessionTab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeSessionTab() {
|
||||||
|
if (!session.closed && Number.isInteger(tab?.id)) {
|
||||||
|
session.closed = tabsRemove(tab.id).catch(() => {});
|
||||||
|
}
|
||||||
|
return session.closed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function requestInternalServiceRequest(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const id = String(message.srId ?? "");
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric SrId is required.");
|
||||||
|
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
|
||||||
|
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
|
||||||
|
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
|
||||||
|
const query = async () => {
|
||||||
|
const result = await fetchJson(url, { method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", Authorization: "Session" }
|
||||||
|
}, "requestInternalServiceRequest", debugRequests);
|
||||||
|
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const result = await query();
|
||||||
|
const items = result.items.flatMap(item => {
|
||||||
|
const children = item.ServiceRequest_SRToInternalSR_Tgt;
|
||||||
|
if (children == null) return [];
|
||||||
|
if (!Array.isArray(children) || children.some(child => !child || typeof child !== "object")) throw new Error("Invalid linked SR collection.");
|
||||||
|
return children;
|
||||||
|
});
|
||||||
|
return { ok: true, items, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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") };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source: src/services/fusion/sr-create.js
|
||||||
|
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders }) {
|
||||||
|
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(email, debugRequests) {
|
||||||
|
const emailAddress = text(email, "current user email");
|
||||||
|
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
|
||||||
|
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
|
||||||
|
}, "requestResourceUser", debugRequests);
|
||||||
|
return numericId(resource?.resourcePartyId, "resourcePartyId");
|
||||||
|
}
|
||||||
|
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.emailAddress, 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.emailAddress, 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestPostServiceRequest, requestPostInternalServiceRequest };
|
||||||
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/current-user.js
|
// Source: src/services/spa/current-user.js
|
||||||
function createSpaCurrentUser({ fetchJson }) {
|
function createSpaCurrentUser({ fetchJson }) {
|
||||||
async function requestCurrentUser() {
|
async function requestCurrentUser() {
|
||||||
@@ -662,12 +1215,6 @@ function createSpaCurrentUser({ fetchJson }) {
|
|||||||
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
|
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
|
||||||
error.debugRequests = debugRequests;
|
error.debugRequests = debugRequests;
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
// The identity token is not needed by content scripts or debug, including on failures.
|
|
||||||
for (const entry of debugRequests) {
|
|
||||||
entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2);
|
|
||||||
entry.responseBodyTruncated = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { requestCurrentUser };
|
return { requestCurrentUser };
|
||||||
@@ -848,11 +1395,11 @@ function createSpaResponseShapes() {
|
|||||||
// Source: src/services/http/background-json.js
|
// Source: src/services/http/background-json.js
|
||||||
function createHttpBackgroundJson({
|
function createHttpBackgroundJson({
|
||||||
previewDebugBody,
|
previewDebugBody,
|
||||||
DEBUG_BODY_LIMIT,
|
observeResponseHeaders = () => {},
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
async function fetchJson(url, options, requestName, debugRequests = null) {
|
async function fetchJson(url, options, requestName, debugRequests = null, { allowEmpty = false } = {}) {
|
||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
||||||
let response;
|
let response;
|
||||||
@@ -865,6 +1412,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, "");
|
||||||
@@ -873,7 +1421,10 @@ function createHttpBackgroundJson({
|
|||||||
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${requestName} failed with status ${response.status}.`);
|
const error = new Error(`${requestName} failed with status ${response.status}.`);
|
||||||
|
error.httpStatus = response.status;
|
||||||
|
debugEntry.error = error.message;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responsePayload.error) {
|
if (responsePayload.error) {
|
||||||
@@ -881,6 +1432,7 @@ function createHttpBackgroundJson({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (allowEmpty && !responsePayload.text.trim()) return null;
|
||||||
return parseJsonResponse(responsePayload.text, requestName);
|
return parseJsonResponse(responsePayload.text, requestName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
error.httpStatus = response.status;
|
error.httpStatus = response.status;
|
||||||
@@ -969,7 +1521,7 @@ function createHttpBackgroundJson({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDebugBody(text) {
|
function createDebugBody(text) {
|
||||||
return previewDebugBody(text, DEBUG_BODY_LIMIT);
|
return previewDebugBody(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDebugHeaders(headers) {
|
function normalizeDebugHeaders(headers) {
|
||||||
@@ -1134,8 +1686,17 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
storageLocalSet
|
||||||
}) {
|
}) {
|
||||||
|
let generation = 0;
|
||||||
|
async function invalidate() {
|
||||||
|
generation += 1;
|
||||||
|
productsCacheState.inFlightProducts.clear();
|
||||||
|
productsCacheState.productsCachePromise = Promise.resolve({});
|
||||||
|
await saveProductsCache({});
|
||||||
|
}
|
||||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
|
const currentGeneration = generation;
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
|
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
@@ -1153,15 +1714,14 @@ function createProductsCache({
|
|||||||
})
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
if (generation === currentGeneration) {
|
||||||
fetchedAt,
|
cache[optyNumber] = { fetchedAt, items };
|
||||||
items
|
|
||||||
};
|
|
||||||
await saveProductsCache(cache).catch(() => {});
|
await saveProductsCache(cache).catch(() => {});
|
||||||
|
}
|
||||||
return { items, fetchedAt };
|
return { items, fetchedAt };
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1207,7 +1767,7 @@ function createProductsCache({
|
|||||||
return productsCacheState.productsCacheWritePromise;
|
return productsCacheState.productsCacheWritePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/fusion/products.js
|
// Source: src/services/fusion/products.js
|
||||||
@@ -1720,12 +2280,27 @@ function startBackgroundApplication(dependencies) {
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
invalidateProductsCache,
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
requestInstallBase,
|
requestInstallBase,
|
||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestWlActions,
|
requestWlActions,
|
||||||
|
requestActionTeamMembers,
|
||||||
|
requestActionFindLookup,
|
||||||
|
requestMgmtAction,
|
||||||
|
requestPostServiceRequest,
|
||||||
|
requestPostInternalServiceRequest,
|
||||||
|
requestInternalServiceRequest,
|
||||||
|
requestSrLookupPrimaryPillar,
|
||||||
|
requestSrLookupWithSession,
|
||||||
|
prepareSrSession,
|
||||||
|
requestSrLookupLanguage,
|
||||||
|
requestSrLookupTechPlatform,
|
||||||
|
requestLookupTypePlatform,
|
||||||
|
requestInternalSrServices,
|
||||||
|
requestSrLookupOpportunityBusiness,
|
||||||
requestCurrentUser,
|
requestCurrentUser,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
@@ -1738,7 +2313,39 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (message.type === messageTypes.requestPostInternalServiceRequest) {
|
||||||
|
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestPostServiceRequest) {
|
||||||
|
requestPostServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.prepareSrSession) {
|
||||||
|
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const srLookups = {
|
||||||
|
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
|
||||||
|
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
|
||||||
|
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
|
||||||
|
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
|
||||||
|
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
|
||||||
|
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
|
||||||
|
};
|
||||||
|
if (Object.hasOwn(srLookups, message.type)) {
|
||||||
|
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.invalidateProductsCache) {
|
||||||
|
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (message.type === messageTypes.getXsrfToken) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
getXsrfTokenCookie()
|
getXsrfTokenCookie()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -1816,6 +2423,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
|
||||||
|
request(message, sender).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (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.",
|
||||||
@@ -1947,8 +2563,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({
|
||||||
@@ -2006,6 +2622,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
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 requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||||
|
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
|
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||||
|
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
|
||||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
@@ -2013,8 +2632,29 @@ 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 srRequestHeaders = createSrRequestHeaders({ runtimeApi });
|
||||||
|
const fusionSrCreate = createFusionSrCreate({
|
||||||
|
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, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
srRequestHeaders.start();
|
||||||
|
startBackgroundApplication({
|
||||||
|
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,
|
||||||
|
|||||||
2551
dist/firefox/content.js
vendored
2551
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
9
dist/firefox/manifest.json
vendored
9
dist/firefox/manifest.json
vendored
@@ -2,14 +2,17 @@
|
|||||||
"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.3",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.3; 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.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -14,14 +14,17 @@ 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.3",
|
||||||
permissions: [
|
permissions: [
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
host_permissions: [
|
host_permissions: [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
content_scripts: [
|
content_scripts: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,7 +17,10 @@ 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"]
|
||||||
.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)};`);
|
||||||
}
|
}
|
||||||
@@ -27,4 +31,4 @@ function composeScript(target, { bootstrap = true } = {}) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { composeScript, sources };
|
module.exports = { composeScript, sources, vendorSources };
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ module.exports = {
|
|||||||
"src/features/opportunities/dashboard-state.js",
|
"src/features/opportunities/dashboard-state.js",
|
||||||
"src/features/debug/state.js",
|
"src/features/debug/state.js",
|
||||||
"src/features/opportunities/columns.js",
|
"src/features/opportunities/columns.js",
|
||||||
|
"src/features/opportunities/badge-content.js",
|
||||||
"src/content/fusion-page.js",
|
"src/content/fusion-page.js",
|
||||||
"src/content/modal.js",
|
"src/content/modal.js",
|
||||||
"src/content/theme.js",
|
"src/content/theme.js",
|
||||||
@@ -32,10 +33,23 @@ module.exports = {
|
|||||||
"src/services/fusion/opportunities.js",
|
"src/services/fusion/opportunities.js",
|
||||||
"src/services/fusion/crm-service-requests.js",
|
"src/services/fusion/crm-service-requests.js",
|
||||||
"src/features/opportunities/crm-service-requests.js",
|
"src/features/opportunities/crm-service-requests.js",
|
||||||
|
"src/features/opportunities/product-activities.js",
|
||||||
|
"src/features/opportunities/refresh-all.js",
|
||||||
|
"src/features/opportunities/activity-cache.js",
|
||||||
|
"src/features/opportunities/activity-progress.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-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",
|
||||||
@@ -73,10 +87,14 @@ module.exports = {
|
|||||||
"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/action-options.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/consumption.js",
|
"src/services/spa/consumption.js",
|
||||||
"src/services/spa/response-shapes.js",
|
"src/services/spa/response-shapes.js",
|
||||||
|
|||||||
@@ -103,14 +103,22 @@ function createAuthConsumerSession({
|
|||||||
const isHtmlFallback = error
|
const isHtmlFallback = error
|
||||||
&& error.code === "INVALID_JSON_RESPONSE"
|
&& error.code === "INVALID_JSON_RESPONSE"
|
||||||
&& error.httpStatus === 200;
|
&& error.httpStatus === 200;
|
||||||
|
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
|
||||||
|
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
|
||||||
|
const retryReason = isSessionUnauthorized
|
||||||
|
? `HTTP ${error.httpStatus} response requires SPA session recovery`
|
||||||
|
: "HTTP 200 response did not contain valid JSON";
|
||||||
const debugEntry = debugRequests[debugRequests.length - 1];
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
if (isHtmlFallback) {
|
if (needsSessionRecovery) {
|
||||||
debugEntry.metadata.retryLimitReached = "true";
|
debugEntry.metadata.retryLimitReached = "true";
|
||||||
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
|
debugEntry.error = isHtmlFallback
|
||||||
|
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
|
||||||
|
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
||||||
error.message = debugEntry.error;
|
error.message = debugEntry.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +146,7 @@ function createAuthConsumerSession({
|
|||||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let allowed = false;
|
let allowed = false;
|
||||||
|
let userId = "";
|
||||||
function ensure() {
|
function ensure() {
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
|
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
|
||||||
appendBackgroundRequestLog(response?.debugRequests);
|
appendBackgroundRequestLog(response?.debugRequests);
|
||||||
const roles = response?.user?.roles;
|
const roles = response?.user?.roles;
|
||||||
|
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : "";
|
||||||
allowed = response?.ok === true && Array.isArray(roles)
|
allowed = response?.ok === true && Array.isArray(roles)
|
||||||
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
|
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
|
||||||
return allowed;
|
return allowed;
|
||||||
@@ -13,5 +15,5 @@ function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, mes
|
|||||||
}
|
}
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
return { ensure, allowsCrmActivities: () => allowed };
|
return { ensure, allowsCrmActivities: () => allowed, getUserId: () => userId };
|
||||||
}
|
}
|
||||||
|
|||||||
109
src/auth/sr-request-headers.js
Normal file
109
src/auth/sr-request-headers.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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)\/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/*`], 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;
|
||||||
|
}
|
||||||
|
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, 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)",
|
||||||
|
|||||||
@@ -56,8 +56,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({
|
||||||
@@ -115,6 +115,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
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 requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||||
|
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
|
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||||
|
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
|
||||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
@@ -122,8 +125,29 @@ 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 srRequestHeaders = createSrRequestHeaders({ runtimeApi });
|
||||||
|
const fusionSrCreate = createFusionSrCreate({
|
||||||
|
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, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
srRequestHeaders.start();
|
||||||
|
startBackgroundApplication({
|
||||||
|
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,
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
invalidateProductsCache,
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
requestInstallBase,
|
requestInstallBase,
|
||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestWlActions,
|
requestWlActions,
|
||||||
|
requestActionTeamMembers,
|
||||||
|
requestActionFindLookup,
|
||||||
|
requestMgmtAction,
|
||||||
|
requestPostServiceRequest,
|
||||||
|
requestPostInternalServiceRequest,
|
||||||
|
requestInternalServiceRequest,
|
||||||
|
requestSrLookupPrimaryPillar,
|
||||||
|
requestSrLookupWithSession,
|
||||||
|
prepareSrSession,
|
||||||
|
requestSrLookupLanguage,
|
||||||
|
requestSrLookupTechPlatform,
|
||||||
|
requestLookupTypePlatform,
|
||||||
|
requestInternalSrServices,
|
||||||
|
requestSrLookupOpportunityBusiness,
|
||||||
requestCurrentUser,
|
requestCurrentUser,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
@@ -19,7 +34,39 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (message.type === messageTypes.requestPostInternalServiceRequest) {
|
||||||
|
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestPostServiceRequest) {
|
||||||
|
requestPostServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.prepareSrSession) {
|
||||||
|
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const srLookups = {
|
||||||
|
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
|
||||||
|
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
|
||||||
|
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
|
||||||
|
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
|
||||||
|
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
|
||||||
|
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
|
||||||
|
};
|
||||||
|
if (Object.hasOwn(srLookups, message.type)) {
|
||||||
|
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.invalidateProductsCache) {
|
||||||
|
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (message.type === messageTypes.getXsrfToken) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
getXsrfTokenCookie()
|
getXsrfTokenCookie()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -97,6 +144,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
|
||||||
|
request(message, sender).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (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.",
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ function createContentApplication(overrides = {}) {
|
|||||||
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();
|
||||||
const opportunitiesTableState = createOpportunitiesTableState();
|
const opportunitiesTableState = createOpportunitiesTableState();
|
||||||
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
||||||
const preferencesState = createPreferencesState(contracts);
|
const preferencesState = createPreferencesState(contracts);
|
||||||
|
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
|
||||||
const productsContentState = createProductsContentState();
|
const productsContentState = createProductsContentState();
|
||||||
const aiSummaryState = createAiSummaryState();
|
const aiSummaryState = createAiSummaryState();
|
||||||
const consumptionPlansState = createConsumptionPlansState();
|
const consumptionPlansState = createConsumptionPlansState();
|
||||||
@@ -54,6 +56,11 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const contentModal = createContentModal({
|
const contentModal = createContentModal({
|
||||||
|
prepareSrSession: () => srSessionPreparation.ensure(),
|
||||||
|
closeActionForm: (...args) => internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
|
||||||
|
mountOpportunityViews: (...args) => opportunityCalendar.mount(...args),
|
||||||
|
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
|
||||||
|
closeCrmSrPopover: () => crmSrPopover.close(),
|
||||||
ensureCurrentUser: () => currentUser.ensure(),
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||||
AUTH_STATUS: contracts.AUTH_STATUS,
|
AUTH_STATUS: contracts.AUTH_STATUS,
|
||||||
@@ -88,6 +95,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||||
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||||
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
||||||
|
createRefreshAllButton: (...args) => refreshAll.createButton(...args),
|
||||||
debugState,
|
debugState,
|
||||||
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
||||||
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
||||||
@@ -110,10 +118,6 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||||
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
|
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
|
||||||
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
|
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
|
||||||
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
|
||||||
reopenWorkloadActions: () => opportunityWorkloadActions.reopen(),
|
|
||||||
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
|
|
||||||
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
|
||||||
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
|
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
|
||||||
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
|
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
|
||||||
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
|
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
|
||||||
@@ -189,7 +193,6 @@ function createContentApplication(overrides = {}) {
|
|||||||
productsContentState,
|
productsContentState,
|
||||||
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
|
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
|
||||||
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
|
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
|
||||||
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
|
|
||||||
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
||||||
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
|
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
|
||||||
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||||
@@ -216,19 +219,96 @@ 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 internalSrSession = createInternalSrSession({
|
||||||
|
environment,
|
||||||
|
query: async srId => {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
const crmSrPopover = createCrmSrPopover({
|
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 });
|
||||||
|
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),
|
||||||
|
activityCache,
|
||||||
|
reportProgress: (...args) => activityProgress.update("sr", ...args),
|
||||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
bindPopover: (...args) => crmSrPopover.bind(...args),
|
bindPopover: (...args) => crmSrPopover.bind(...args),
|
||||||
closePopover: (...args) => crmSrPopover.close(...args),
|
closePopover: (...args) => crmSrPopover.close(...args),
|
||||||
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(),
|
||||||
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
||||||
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
||||||
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
|
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
|
||||||
@@ -266,6 +346,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const opportunitiesFilters = createOpportunitiesFilters({
|
const opportunitiesFilters = createOpportunitiesFilters({
|
||||||
|
matchesActivities: item => activityFilters.matches(item),
|
||||||
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
|
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
|
||||||
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
||||||
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
|
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
|
||||||
@@ -329,8 +410,57 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
|
const refreshAll = createRefreshAll({
|
||||||
|
createRefreshIcon: () => contentIcons.createRefreshIcon(),
|
||||||
|
messageTypes, activityCache, opportunitiesQueryState, productsContentState,
|
||||||
|
consumptionPlansState, consumptionTrendState, aiSummaryState, environment,
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||||
|
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
resetRelatedData: () => {
|
||||||
|
opportunityCrmServiceRequests.reset();
|
||||||
|
opportunityWorkloadActions.reset();
|
||||||
|
consumptionPlansView.closeAccountPlansModal();
|
||||||
|
installBaseView.closeInstallBaseModal();
|
||||||
|
serviceRequestsView.closeServiceRequestsModal();
|
||||||
|
installBaseAction.resetRequests();
|
||||||
|
serviceRequestsAction.resetRequests();
|
||||||
|
ratecardController.resetRequests();
|
||||||
|
consumptionPlanDetailController.resetRequests();
|
||||||
|
productsContentJobs.renderProductsProgress();
|
||||||
|
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
|
||||||
|
const srSessionPreparation = createSrSessionPreparation({
|
||||||
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }),
|
||||||
|
reportProgress: state => activityProgress.update("session", state),
|
||||||
|
appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries)
|
||||||
|
});
|
||||||
|
const activityFilters = createActivityFilters({
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
getSrSummary: number => opportunityCrmServiceRequests.getSummary(number),
|
||||||
|
getActionSummary: number => opportunityWorkloadActions.getSummary(number),
|
||||||
|
renderTable: () => opportunitiesTable.renderOpportunitiesTable(),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const productActivities = createProductActivities({
|
||||||
|
productsContentState, opportunitiesQueryState,
|
||||||
|
loadCrm: (...args) => opportunityCrmServiceRequests.load(...args),
|
||||||
|
loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
|
||||||
|
blockActions: products => opportunityWorkloadActions.blockProducts(products),
|
||||||
|
render: () => {
|
||||||
|
productsContentJobs.renderProductsProgress();
|
||||||
|
productsContentJobs.queueProductsTableRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
const productsContentJobs = createProductsContentJobs({
|
const productsContentJobs = createProductsContentJobs({
|
||||||
loadWorkloadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
|
beginProductActivities: (...args) => productActivities.begin(...args),
|
||||||
|
completeProductActivities: (...args) => productActivities.complete(...args),
|
||||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
messageTypes,
|
messageTypes,
|
||||||
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||||
@@ -342,7 +472,35 @@ function createContentApplication(overrides = {}) {
|
|||||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
|
const workloadActionOptions = createWorkloadActionOptions({
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
messageTypes
|
||||||
|
});
|
||||||
|
const workloadActionForm = createWorkloadActionForm({
|
||||||
|
searchableSelect,
|
||||||
|
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
|
||||||
|
saveAction: (...args) => workloadActionOptions.save(...args),
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
getCurrentUserId: () => currentUser.getUserId(),
|
||||||
|
requestTeamMembers: (...args) => workloadActionOptions.requestTeamMembers(...args),
|
||||||
|
requestLookup: (...args) => workloadActionOptions.requestLookup(...args),
|
||||||
|
getProducts: number => productsContentState.opptyProductsByNumber.get(String(number))?.items || [],
|
||||||
|
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
|
||||||
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
isMissingAction: number => opportunityWorkloadActions.getSummary(number)?.state === "missing",
|
||||||
|
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||||
|
environment
|
||||||
|
});
|
||||||
const opportunityWorkloadActions = createOpportunityWorkloadActions({
|
const opportunityWorkloadActions = createOpportunityWorkloadActions({
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
|
createActionButton: number => workloadActionForm.createButton(number),
|
||||||
|
closeActionForm: (...args) => workloadActionForm.close(...args),
|
||||||
|
activityCache,
|
||||||
|
reportProgress: (...args) => activityProgress.update("actions", ...args),
|
||||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
authSessionState,
|
authSessionState,
|
||||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
@@ -352,6 +510,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
productsContentState, messageTypes, environment
|
productsContentState, messageTypes, environment
|
||||||
});
|
});
|
||||||
const productsDetails = createProductsDetails({
|
const productsDetails = createProductsDetails({
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
OPPORTUNITIES_COLUMNS: columns,
|
OPPORTUNITIES_COLUMNS: columns,
|
||||||
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
||||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
@@ -497,7 +656,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)
|
||||||
});
|
});
|
||||||
@@ -515,6 +673,9 @@ function createContentApplication(overrides = {}) {
|
|||||||
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
opportunityCalendar,
|
||||||
|
workloadActionForm,
|
||||||
|
activityFilters,
|
||||||
start, contracts,
|
start, contracts,
|
||||||
opportunityWorkloadActions,
|
opportunityWorkloadActions,
|
||||||
fusionCrmServiceRequests, opportunityCrmServiceRequests,
|
fusionCrmServiceRequests, opportunityCrmServiceRequests,
|
||||||
@@ -552,6 +713,11 @@ function createContentApplication(overrides = {}) {
|
|||||||
preferencesStorage,
|
preferencesStorage,
|
||||||
preferencesView,
|
preferencesView,
|
||||||
productsContentJobs,
|
productsContentJobs,
|
||||||
|
productActivities,
|
||||||
|
activityProgress,
|
||||||
|
srSessionPreparation,
|
||||||
|
refreshAll,
|
||||||
|
activityCache,
|
||||||
productsDetails,
|
productsDetails,
|
||||||
aiSummaryContent,
|
aiSummaryContent,
|
||||||
consumptionPlansAction,
|
consumptionPlansAction,
|
||||||
|
|||||||
@@ -70,5 +70,40 @@ function createContentIcons({
|
|||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createThemeIcon, createSettingsIcon, createCloseIcon };
|
function createRefreshIcon() {
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
svg.setAttribute("focusable", "false");
|
||||||
|
path.setAttribute("d", "M20 7v5h-5M4 17v-5h5M6.1 6.1A8 8 0 0 1 19.7 10M4.3 14a8 8 0 0 0 13.6 3.9");
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ function createContentModal({
|
|||||||
closeAccountPlansModal,
|
closeAccountPlansModal,
|
||||||
closeInstallBaseModal,
|
closeInstallBaseModal,
|
||||||
closeServiceRequestsModal,
|
closeServiceRequestsModal,
|
||||||
|
closeActionForm,
|
||||||
closeCustomerFilterPanel,
|
closeCustomerFilterPanel,
|
||||||
closeOwnerFilterPanel,
|
closeOwnerFilterPanel,
|
||||||
closePreferencesModal,
|
closePreferencesModal,
|
||||||
createSettingsIcon,
|
createSettingsIcon,
|
||||||
|
createRefreshAllButton,
|
||||||
|
createActivityFilterControls,
|
||||||
|
mountOpportunityViews,
|
||||||
debugState,
|
debugState,
|
||||||
ensureExtensionStyles,
|
ensureExtensionStyles,
|
||||||
getCustomerOptions,
|
getCustomerOptions,
|
||||||
@@ -51,12 +55,10 @@ function createContentModal({
|
|||||||
renderStageFilterButtons,
|
renderStageFilterButtons,
|
||||||
renderStatusFilterButtons,
|
renderStatusFilterButtons,
|
||||||
requestOpportunitiesForPeriod,
|
requestOpportunitiesForPeriod,
|
||||||
loadCrmServiceRequests,
|
closeCrmSrPopover,
|
||||||
resetCrmServiceRequests,
|
|
||||||
resetWorkloadActions,
|
|
||||||
reopenWorkloadActions,
|
|
||||||
requestTokenRelayOnce,
|
requestTokenRelayOnce,
|
||||||
ensureCurrentUser,
|
ensureCurrentUser,
|
||||||
|
prepareSrSession,
|
||||||
resetCustomerAndOwnerFiltersForPeriod,
|
resetCustomerAndOwnerFiltersForPeriod,
|
||||||
resetOpportunitiesTable,
|
resetOpportunitiesTable,
|
||||||
restoreFilterPreferences,
|
restoreFilterPreferences,
|
||||||
@@ -83,15 +85,13 @@ 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();
|
||||||
ensureCurrentUser().then(() => {
|
ensureCurrentUser().then(() => {
|
||||||
if (existingModal.hidden) return;
|
if (existingModal.hidden) return;
|
||||||
reopenWorkloadActions();
|
return requestTokenRelayOnce();
|
||||||
return requestTokenRelayOnce().then(token => {
|
|
||||||
if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion);
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.then(() => renderOpportunitiesTable()).catch(() => {});
|
.then(() => renderOpportunitiesTable()).catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -114,7 +114,6 @@ function createContentModal({
|
|||||||
overlay.setAttribute("role", "dialog");
|
overlay.setAttribute("role", "dialog");
|
||||||
overlay.setAttribute("aria-modal", "true");
|
overlay.setAttribute("aria-modal", "true");
|
||||||
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
||||||
overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle");
|
|
||||||
|
|
||||||
const shell = document.createElement("section");
|
const shell = document.createElement("section");
|
||||||
shell.className = "opportunities-extension-shell";
|
shell.className = "opportunities-extension-shell";
|
||||||
@@ -126,10 +125,6 @@ function createContentModal({
|
|||||||
title.id = "opportunities-extension-title";
|
title.id = "opportunities-extension-title";
|
||||||
title.textContent = "Opportunities Extension";
|
title.textContent = "Opportunities Extension";
|
||||||
|
|
||||||
const subtitle = document.createElement("p");
|
|
||||||
subtitle.id = "opportunities-extension-subtitle";
|
|
||||||
subtitle.textContent = "Lista de oportunidades do HCM Opportunities List";
|
|
||||||
|
|
||||||
const authBadge = document.createElement("span");
|
const authBadge = document.createElement("span");
|
||||||
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
||||||
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
||||||
@@ -149,9 +144,10 @@ function createContentModal({
|
|||||||
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
||||||
productsCacheTimestamp.setAttribute("role", "status");
|
productsCacheTimestamp.setAttribute("role", "status");
|
||||||
productsCacheTimestamp.hidden = true;
|
productsCacheTimestamp.hidden = true;
|
||||||
subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp);
|
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
|
||||||
|
|
||||||
const titleBlock = document.createElement("div");
|
const titleBlock = document.createElement("div");
|
||||||
|
titleBlock.className = "opportunities-extension-title-row";
|
||||||
titleBlock.append(title, subtitleRow);
|
titleBlock.append(title, subtitleRow);
|
||||||
|
|
||||||
const closeButton = document.createElement("button");
|
const closeButton = document.createElement("button");
|
||||||
@@ -180,7 +176,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, preferencesButton, closeButton);
|
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
|
||||||
|
|
||||||
header.append(titleBlock, headerActions);
|
header.append(titleBlock, headerActions);
|
||||||
|
|
||||||
@@ -389,7 +385,8 @@ function createContentModal({
|
|||||||
|
|
||||||
statusFilter.append(statusFilterLabel, statusFilters);
|
statusFilter.append(statusFilterLabel, statusFilters);
|
||||||
form.addEventListener("submit", (event) => event.preventDefault());
|
form.addEventListener("submit", (event) => event.preventDefault());
|
||||||
form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter);
|
const badgeFilters = createActivityFilterControls(form, statusFilter, stageFilter);
|
||||||
|
form.append(field, customerFilter, ownerFilter, tableSearchField, badgeFilters);
|
||||||
body.append(form);
|
body.append(form);
|
||||||
|
|
||||||
const stageDashboard = document.createElement("section");
|
const stageDashboard = document.createElement("section");
|
||||||
@@ -407,7 +404,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;
|
||||||
@@ -426,6 +423,7 @@ function createContentModal({
|
|||||||
renderOwnerFilter();
|
renderOwnerFilter();
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
requestOpportunitiesForPeriod(select.value);
|
requestOpportunitiesForPeriod(select.value);
|
||||||
|
prepareSrSession?.();
|
||||||
select.focus();
|
select.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,8 +467,7 @@ function createContentModal({
|
|||||||
|
|
||||||
function closeOpportunitiesModal() {
|
function closeOpportunitiesModal() {
|
||||||
resetAuthCompletion();
|
resetAuthCompletion();
|
||||||
resetWorkloadActions(false);
|
closeCrmSrPopover();
|
||||||
resetCrmServiceRequests();
|
|
||||||
const modal = document.getElementById(MODAL_ID);
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
if (modal) {
|
if (modal) {
|
||||||
@@ -478,6 +475,7 @@ function createContentModal({
|
|||||||
closeAccountPlansModal();
|
closeAccountPlansModal();
|
||||||
closeInstallBaseModal();
|
closeInstallBaseModal();
|
||||||
closeServiceRequestsModal();
|
closeServiceRequestsModal();
|
||||||
|
closeActionForm(false);
|
||||||
modal.hidden = true;
|
modal.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +504,10 @@ function createContentModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
|
if (closeActionForm()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (closeServiceRequestsModal()) {
|
if (closeServiceRequestsModal()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
|
|||||||
current.promise = (async () => {
|
current.promise = (async () => {
|
||||||
try {
|
try {
|
||||||
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
|
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
|
||||||
if (!pending.has(subPlanNum)) pending.set(subPlanNum, request(subPlanNum).finally(() => pending.delete(subPlanNum)));
|
if (!pending.has(subPlanNum)) {
|
||||||
|
const promise = request(subPlanNum).finally(() => { if (pending.get(subPlanNum) === promise) pending.delete(subPlanNum); });
|
||||||
|
pending.set(subPlanNum, promise);
|
||||||
|
}
|
||||||
const items = await pending.get(subPlanNum);
|
const items = await pending.get(subPlanNum);
|
||||||
if (!section.isConnected || states.get(section) !== current) return;
|
if (!section.isConnected || states.get(section) !== current) return;
|
||||||
current.status = "ready";
|
current.status = "ready";
|
||||||
@@ -59,5 +62,5 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
|
|||||||
})();
|
})();
|
||||||
return current.promise;
|
return current.promise;
|
||||||
}
|
}
|
||||||
return { createPlanDetailSection, loadPlanDetailForPanel };
|
return { createPlanDetailSection, loadPlanDetailForPanel, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ function createConsumptionPlansAction({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
|
const currentRequests = consumptionPlansState.accountPlansRequestState;
|
||||||
|
currentRequests.set(optyNumber, { status: "loading" });
|
||||||
openAccountPlansModal(opportunity, [], "loading");
|
openAccountPlansModal(opportunity, [], "loading");
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
requestConsumerTokenServiceOnce()
|
requestConsumerTokenServiceOnce()
|
||||||
@@ -41,6 +42,7 @@ function createConsumptionPlansAction({
|
|||||||
authorizations
|
authorizations
|
||||||
}))
|
}))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||||
appendBackgroundRequestLog(response && response.debugRequests);
|
appendBackgroundRequestLog(response && response.debugRequests);
|
||||||
|
|
||||||
if (!response || !response.ok) {
|
if (!response || !response.ok) {
|
||||||
@@ -54,6 +56,7 @@ function createConsumptionPlansAction({
|
|||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||||
const errorMessage = error.message || "Unable to load cloud consumption.";
|
const errorMessage = error.message || "Unable to load cloud consumption.";
|
||||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||||
status: "error",
|
status: "error",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ function createDebugContentView({
|
|||||||
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
|
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
|
||||||
if (entry.name === "requestOpportunities") return "opportunities";
|
if (entry.name === "requestOpportunities") return "opportunities";
|
||||||
if (entry.name === "requestOpptyProducts") return "products";
|
if (entry.name === "requestOpptyProducts") return "products";
|
||||||
if (entry.name === "requestWlActions") 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 (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") 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";
|
||||||
}
|
}
|
||||||
@@ -222,7 +223,14 @@ function createDebugContentView({
|
|||||||
method.className = "opportunities-extension-debug-method";
|
method.className = "opportunities-extension-debug-method";
|
||||||
method.textContent = entry.method || "GET";
|
method.textContent = entry.method || "GET";
|
||||||
method.setAttribute("data-method", method.textContent.toUpperCase());
|
method.setAttribute("data-method", method.textContent.toUpperCase());
|
||||||
summaryMeta.append(startedTime, method, httpStatus, status);
|
const duration = document.createElement("span");
|
||||||
|
duration.className = "opportunities-extension-debug-duration";
|
||||||
|
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
|
||||||
|
duration.textContent = hasDuration
|
||||||
|
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
|
||||||
|
: entry.status === "pending" ? "In progress" : "N/D";
|
||||||
|
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
|
||||||
|
summaryMeta.append(startedTime, method, httpStatus, status, duration);
|
||||||
|
|
||||||
heading.append(name, summaryMeta);
|
heading.append(name, summaryMeta);
|
||||||
|
|
||||||
@@ -242,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);
|
||||||
|
|||||||
@@ -214,3 +214,10 @@
|
|||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
|
||||||
outline-color: #8cc8d3;
|
outline-color: #8cc8d3;
|
||||||
}
|
}
|
||||||
|
.opportunities-extension-debug-duration {
|
||||||
|
color: #55514d;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-duration { color: #d6d2ce; }
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
|
|||||||
try {
|
try {
|
||||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||||
if (!pending.has(partyNumber)) {
|
if (!pending.has(partyNumber)) {
|
||||||
const request = load(partyNumber).finally(() => pending.delete(partyNumber));
|
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
|
||||||
pending.set(partyNumber, request);
|
pending.set(partyNumber, request);
|
||||||
}
|
}
|
||||||
view.render(await pending.get(partyNumber));
|
view.render(await pending.get(partyNumber));
|
||||||
@@ -49,5 +49,5 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
|
|||||||
});
|
});
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return { createInstallBaseButton };
|
return { createInstallBaseButton, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/features/opportunities/activity-cache.js
Normal file
67
src/features/opportunities/activity-cache.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
function createActivityCache({ authSessionState, preferencesState, environment }) {
|
||||||
|
const { browser, chrome } = environment;
|
||||||
|
const invalidatedKey = "opportunitiesExtension.activities.invalidatedAt.v1";
|
||||||
|
async function invalidate() {
|
||||||
|
await storage("set", { [invalidatedKey]: Date.now() });
|
||||||
|
const entries = await storage("get", null);
|
||||||
|
const keys = Object.keys(entries || {}).filter(key => key.startsWith("opportunitiesExtension.activities.v1:"));
|
||||||
|
if (keys.length) await storage("remove", keys);
|
||||||
|
}
|
||||||
|
function storage(method, argument) {
|
||||||
|
if (browser?.storage?.local) return browser.storage.local[method](argument);
|
||||||
|
if (!chrome?.storage?.local) return Promise.reject(new Error("Extension storage unavailable."));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.storage.local[method](argument, result => {
|
||||||
|
const error = chrome.runtime?.lastError;
|
||||||
|
if (error) reject(new Error(error.message));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function context(kind, id, timestamp) {
|
||||||
|
const principal = authSessionState.principal;
|
||||||
|
const fetchedAt = Number(timestamp);
|
||||||
|
const hours = Number(preferencesState.opptyProductsCacheHours);
|
||||||
|
const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 720) * 3600000 : 86400000;
|
||||||
|
if (!principal || !Number.isFinite(fetchedAt) || fetchedAt <= 0 || fetchedAt > Date.now() || Date.now() - fetchedAt >= ttl) return null;
|
||||||
|
const identity = [principal, kind, String(id)];
|
||||||
|
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`;
|
||||||
|
// SR snapshots from different product updates must not overwrite each other.
|
||||||
|
if (kind === "sr") identity.push(fetchedAt);
|
||||||
|
return { key: `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`, legacyKey, fetchedAt, ttl };
|
||||||
|
}
|
||||||
|
async function read(kind, id, timestamp) {
|
||||||
|
const scope = context(kind, id, timestamp);
|
||||||
|
if (!scope) return null;
|
||||||
|
try {
|
||||||
|
const invalidated = await storage("get", invalidatedKey);
|
||||||
|
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return null;
|
||||||
|
const result = await storage("get", scope.key);
|
||||||
|
let entry = result?.[scope.key];
|
||||||
|
if (!entry && scope.key !== scope.legacyKey) {
|
||||||
|
const legacy = await storage("get", scope.legacyKey);
|
||||||
|
entry = legacy?.[scope.legacyKey];
|
||||||
|
}
|
||||||
|
return entry?.fetchedAt === scope.fetchedAt && Array.isArray(entry.items)
|
||||||
|
&& entry.items.every(item => item && typeof item === "object" && !Array.isArray(item))
|
||||||
|
&& typeof entry.hasNext === "boolean" ? entry : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
async function write(kind, id, timestamp, items, hasNext = false) {
|
||||||
|
const scope = context(kind, id, timestamp);
|
||||||
|
if (!scope || !Array.isArray(items)) return;
|
||||||
|
try {
|
||||||
|
const invalidated = await storage("get", invalidatedKey);
|
||||||
|
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return;
|
||||||
|
await storage("set", { [scope.key]: { fetchedAt: scope.fetchedAt, items, hasNext: Boolean(hasNext) } });
|
||||||
|
if (kind === "sr") {
|
||||||
|
const entries = await storage("get", null);
|
||||||
|
const prefix = `${scope.legacyKey.slice(0, -1)},`;
|
||||||
|
const expired = Object.keys(entries || {}).filter(key => key.startsWith(prefix)
|
||||||
|
&& Number(entries[key]?.fetchedAt) <= Math.max(Date.now() - scope.ttl, Number(invalidated?.[invalidatedKey] || 0)));
|
||||||
|
if (expired.length) await storage("remove", expired);
|
||||||
|
}
|
||||||
|
} catch { /* Storage failure must not discard successfully fetched data. */ }
|
||||||
|
}
|
||||||
|
return { invalidate, read, write };
|
||||||
|
}
|
||||||
91
src/features/opportunities/activity-filters.js
Normal file
91
src/features/opportunities/activity-filters.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSummary, renderTable, setBadgeContent, environment }) {
|
||||||
|
const selections = { sr: new Set(["matched", "missing"]), actions: new Set(["matched", "missing"]) };
|
||||||
|
let container = null;
|
||||||
|
let form = null;
|
||||||
|
const groups = [];
|
||||||
|
const buttons = [];
|
||||||
|
|
||||||
|
function createControls(parentForm, statusFilter, stageFilter) {
|
||||||
|
form = parentForm;
|
||||||
|
groups.length = 0;
|
||||||
|
buttons.length = 0;
|
||||||
|
container = environment.document.createElement("div");
|
||||||
|
container.className = "opportunities-extension-badge-filters";
|
||||||
|
for (const [kind, title, options] of [
|
||||||
|
["sr", "Service Request", ["matched", "under4", "missing"]],
|
||||||
|
["actions", "WL Action", ["matched", "missing"]]
|
||||||
|
]) {
|
||||||
|
const section = environment.document.createElement("section");
|
||||||
|
section.className = "opportunities-extension-activity-filter";
|
||||||
|
const label = environment.document.createElement("span");
|
||||||
|
label.textContent = title;
|
||||||
|
const tooltip = kind === "sr"
|
||||||
|
? "Whether a Service Request has been created for the opportunities."
|
||||||
|
: "Whether the Action is registered in Workload Workbench.";
|
||||||
|
label.title = tooltip;
|
||||||
|
const controls = environment.document.createElement("div");
|
||||||
|
controls.className = "opportunities-extension-activity-filter-controls";
|
||||||
|
controls.setAttribute("role", "group");
|
||||||
|
controls.setAttribute("aria-label", `Filter by ${title}`);
|
||||||
|
for (const value of options) {
|
||||||
|
const button = environment.document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-activity-filter-button";
|
||||||
|
button.setAttribute("data-value", value);
|
||||||
|
setBadgeContent(button, kind, value === "under4" ? "< 4h" : "");
|
||||||
|
const description = value === "under4"
|
||||||
|
? "At least one SR with reported hours below 4; replaces other SR filters."
|
||||||
|
: `${kind === "sr" ? "Service Request" : "Action"} ${value === "matched" ? "registered" : "not registered"}`;
|
||||||
|
button.title = `${tooltip}\n${description}`;
|
||||||
|
button.setAttribute("aria-label", description);
|
||||||
|
button.addEventListener("click", () => toggle(kind, value));
|
||||||
|
buttons.push({ button, kind, value });
|
||||||
|
controls.append(button);
|
||||||
|
}
|
||||||
|
section.append(label, controls);
|
||||||
|
groups.push(section);
|
||||||
|
container.append(section);
|
||||||
|
}
|
||||||
|
container.append(statusFilter, stageFilter);
|
||||||
|
render();
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!container) return;
|
||||||
|
const allowed = allowsCrmActivities();
|
||||||
|
form.setAttribute("data-activity-filters", String(allowed));
|
||||||
|
for (const group of groups) group.hidden = !allowed;
|
||||||
|
for (const { button, kind, value } of buttons) {
|
||||||
|
button.setAttribute("aria-pressed", String(selections[kind].has(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(kind, value) {
|
||||||
|
const selected = selections[kind];
|
||||||
|
if (!selected || !allowsCrmActivities()) return;
|
||||||
|
if (selected.has(value)) selected.delete(value);
|
||||||
|
else {
|
||||||
|
if (value === "under4") selected.clear();
|
||||||
|
else selected.delete("under4");
|
||||||
|
selected.add(value);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSelection(selected, summary) {
|
||||||
|
// Unrestricted filters retain rows whose activities are not known yet.
|
||||||
|
if (selected.has("matched") && selected.has("missing")) return true;
|
||||||
|
if (!summary) return false;
|
||||||
|
if (selected.has("under4")) return summary.state === "matched" && summary.hasUnderFourHours;
|
||||||
|
return selected.has(summary.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(item) {
|
||||||
|
if (!allowsCrmActivities()) return true;
|
||||||
|
return matchesSelection(selections.sr, getSrSummary(item.OptyNumber))
|
||||||
|
&& matchesSelection(selections.actions, getActionSummary(item.OptyNumber));
|
||||||
|
}
|
||||||
|
return { createControls, render, toggle, matches };
|
||||||
|
}
|
||||||
39
src/features/opportunities/activity-progress.js
Normal file
39
src/features/opportunities/activity-progress.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
function createActivityProgress({ MODAL_ID, environment }) {
|
||||||
|
const { document, setTimeout, clearTimeout, matchMedia } = environment;
|
||||||
|
const entries = new Map();
|
||||||
|
function update(key, state, completed = 0, total = null) {
|
||||||
|
const host = document?.querySelector?.(`#${MODAL_ID} .opportunities-extension-subtitle-row`);
|
||||||
|
if (!host) return;
|
||||||
|
let entry = entries.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "opportunities-extension-products-progress";
|
||||||
|
badge.setAttribute("role", "status");
|
||||||
|
badge.setAttribute("aria-live", "polite");
|
||||||
|
host.append(badge);
|
||||||
|
entry = { badge, timer: null, hideTimer: null, state: "idle" };
|
||||||
|
entries.set(key, entry);
|
||||||
|
}
|
||||||
|
if (state === "complete" && entry.state === "complete") return;
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
clearTimeout(entry.hideTimer);
|
||||||
|
entry.state = state;
|
||||||
|
const label = key === "session" ? "SR session" : key === "sr" ? "Service Requests" : "Actions";
|
||||||
|
entry.badge.hidden = state === "idle";
|
||||||
|
entry.badge.setAttribute("data-state", state);
|
||||||
|
entry.badge.setAttribute("data-fading", "false");
|
||||||
|
entry.badge.setAttribute("aria-busy", String(state === "running"));
|
||||||
|
const count = total === null ? String(completed) : `${completed}/${total}`;
|
||||||
|
entry.badge.textContent = state === "error" ? `${label} unavailable`
|
||||||
|
: state === "complete-with-errors" ? `${label} ${count} (errors)`
|
||||||
|
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
|
||||||
|
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
|
||||||
|
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters";
|
||||||
|
if (state === "complete") entry.timer = setTimeout(() => {
|
||||||
|
entry.badge.setAttribute("data-fading", "true");
|
||||||
|
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
entry.hideTimer = setTimeout(() => { entry.badge.hidden = true; }, reduced ? 0 : 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
63
src/features/opportunities/badge-content.js
Normal file
63
src/features/opportunities/badge-content.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Lucide icons: package, chart-no-axes-column-increasing, clipboard-list, list-checks.
|
||||||
|
* https://github.com/lucide-icons/lucide (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.
|
||||||
|
*/
|
||||||
|
function createOpportunityBadgeContent({ environment }) {
|
||||||
|
const shapes = {
|
||||||
|
products: [
|
||||||
|
["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }],
|
||||||
|
["path", { d: "M12 22V12" }],
|
||||||
|
["polyline", { points: "3.29 7 12 12 20.71 7" }],
|
||||||
|
["path", { d: "m7.5 4.27 9 5.15" }]
|
||||||
|
],
|
||||||
|
ramp: [
|
||||||
|
["path", { d: "M5 21v-6" }],
|
||||||
|
["path", { d: "M12 21V9" }],
|
||||||
|
["path", { d: "M19 21V3" }]
|
||||||
|
],
|
||||||
|
sr: [
|
||||||
|
["rect", { width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1" }],
|
||||||
|
["path", { d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }],
|
||||||
|
["path", { d: "M12 11h4" }],
|
||||||
|
["path", { d: "M12 16h4" }],
|
||||||
|
["path", { d: "M8 11h.01" }],
|
||||||
|
["path", { d: "M8 16h.01" }]
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
["path", { d: "M13 5h8" }],
|
||||||
|
["path", { d: "M13 12h8" }],
|
||||||
|
["path", { d: "M13 19h8" }],
|
||||||
|
["path", { d: "m3 17 2 2 4-4" }],
|
||||||
|
["path", { d: "m3 7 2 2 4-4" }]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
function setContent(badge, kind, value) {
|
||||||
|
badge.textContent = String(value);
|
||||||
|
const namespace = "http://www.w3.org/2000/svg";
|
||||||
|
const icon = environment.document.createElementNS(namespace, "svg");
|
||||||
|
for (const [name, attribute] of Object.entries({
|
||||||
|
class: "opportunities-extension-badge-icon", viewBox: "0 0 24 24",
|
||||||
|
width: "16", height: "16", fill: "none", stroke: "currentColor",
|
||||||
|
"stroke-width": "2.25", "stroke-linecap": "round", "stroke-linejoin": "round",
|
||||||
|
"aria-hidden": "true", focusable: "false", "data-badge-icon": kind
|
||||||
|
})) icon.setAttribute(name, attribute);
|
||||||
|
for (const [tag, attributes] of shapes[kind]) {
|
||||||
|
const shape = environment.document.createElementNS(namespace, tag);
|
||||||
|
for (const [name, attribute] of Object.entries(attributes)) shape.setAttribute(name, attribute);
|
||||||
|
icon.append(shape);
|
||||||
|
}
|
||||||
|
badge.append(icon);
|
||||||
|
}
|
||||||
|
return { setContent };
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ function createOpportunitiesController({
|
|||||||
productsContentState,
|
productsContentState,
|
||||||
renderProductsProgress,
|
renderProductsProgress,
|
||||||
requestOpportunitiesPage,
|
requestOpportunitiesPage,
|
||||||
loadCrmServiceRequests,
|
|
||||||
resetCrmServiceRequests,
|
resetCrmServiceRequests,
|
||||||
resetWorkloadActions,
|
resetWorkloadActions,
|
||||||
requestOpptyProductsInBackground,
|
requestOpptyProductsInBackground,
|
||||||
@@ -24,6 +23,7 @@ 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();
|
||||||
await ensureCurrentUser();
|
await ensureCurrentUser();
|
||||||
if (!isCurrentPeriodRequest(requestVersion)) return;
|
if (!isCurrentPeriodRequest(requestVersion)) return;
|
||||||
|
|
||||||
@@ -70,7 +70,6 @@ function createOpportunitiesController({
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
cacheHits: 0
|
cacheHits: 0
|
||||||
};
|
};
|
||||||
productsContentState.productsLastCachedAt = 0;
|
|
||||||
consumptionPlansState.accountPlansRequestState = new Map();
|
consumptionPlansState.accountPlansRequestState = new Map();
|
||||||
consumptionTrendState.consumptionTrendCache = new Map();
|
consumptionTrendState.consumptionTrendCache = new Map();
|
||||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||||
@@ -84,7 +83,6 @@ function createOpportunitiesController({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadCrmServiceRequests(token, requestVersion);
|
|
||||||
if (!isCurrentPeriodRequest(requestVersion)) return;
|
if (!isCurrentPeriodRequest(requestVersion)) return;
|
||||||
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
|
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,38 @@
|
|||||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, 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;
|
||||||
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
|
let loadedItems = [];
|
||||||
function load(token, version) {
|
function reset() { closeSrForm(); closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
|
||||||
|
function load(token, version, cacheOptions = null) {
|
||||||
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
||||||
if (!pending) pending = run(token, version, generation);
|
if (status === "ready") {
|
||||||
|
return Promise.resolve(cacheOptions ? activityCache.write("sr", "all", cacheOptions.cachedAt, loadedItems) : undefined).then(() => true);
|
||||||
|
}
|
||||||
|
if (!pending) {
|
||||||
|
const request = run(token, version, generation, cacheOptions);
|
||||||
|
pending = request;
|
||||||
|
request.finally(() => { if (pending === request) pending = null; });
|
||||||
|
}
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
async function run(token, version, currentGeneration) {
|
async function run(token, version, currentGeneration, cacheOptions) {
|
||||||
const isActive = () => currentGeneration === generation;
|
const isActive = () => currentGeneration === generation;
|
||||||
status = "loading";
|
status = "loading";
|
||||||
try {
|
try {
|
||||||
const items = await requestCrmServiceRequest(token, version, isActive);
|
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
|
||||||
if (!items || !isActive()) return;
|
if (!isActive()) return false;
|
||||||
|
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return false; }
|
||||||
|
let items = cached?.items;
|
||||||
|
if (!items) {
|
||||||
|
reportProgress("running", 0);
|
||||||
|
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => {
|
||||||
|
if (isActive()) reportProgress("running", count, total);
|
||||||
|
});
|
||||||
|
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
|
||||||
|
}
|
||||||
|
if (!items || !isActive()) return false;
|
||||||
const grouped = new Map();
|
const grouped = new Map();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
||||||
@@ -24,23 +42,44 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
grouped.get(key).push(item);
|
grouped.get(key).push(item);
|
||||||
}
|
}
|
||||||
byOpportunity = grouped;
|
byOpportunity = grouped;
|
||||||
|
loadedItems = items;
|
||||||
status = "ready";
|
status = "ready";
|
||||||
|
if (!cached) reportProgress("complete", items.length, items.length);
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
if (isActive()) status = "error";
|
if (isActive()) { status = "error"; reportProgress("error"); }
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function getSummary(optyNumber) {
|
||||||
|
if (!allowsCrmActivities() || !optyNumber) return null;
|
||||||
|
const items = byOpportunity.get(String(optyNumber)) || [];
|
||||||
|
const hours = items.map(item => item.ReportedHours_c).filter(value =>
|
||||||
|
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
|
||||||
|
).map(Number);
|
||||||
|
return {
|
||||||
|
state: status === "ready" ? items.length ? "matched" : "missing" : status,
|
||||||
|
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
|
||||||
|
hasUnderFourHours: status === "ready" && hours.some(value => value < 4)
|
||||||
|
};
|
||||||
|
}
|
||||||
function createBadge(optyNumber) {
|
function createBadge(optyNumber) {
|
||||||
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
|
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
|
||||||
const items = byOpportunity.get(String(optyNumber)) || [];
|
const items = byOpportunity.get(String(optyNumber)) || [];
|
||||||
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
|
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
|
||||||
badge.className = "opportunities-extension-crm-sr-badge";
|
badge.className = "opportunities-extension-crm-sr-badge";
|
||||||
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
|
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
|
||||||
badge.textContent = status === "error" ? "SR ?" : items.length ? "\u2705 SR" : "\u26d4 SR";
|
const summary = getSummary(optyNumber);
|
||||||
|
const reportedHours = summary.reportedHours !== null
|
||||||
|
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
|
||||||
|
: "N/D";
|
||||||
|
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h");
|
||||||
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
|
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
|
||||||
const descriptions = items.map(item => [
|
const descriptions = items.map(item => [
|
||||||
`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)}`
|
||||||
@@ -49,10 +88,11 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
|
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
|
||||||
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
||||||
badge.setAttribute("aria-label", description);
|
badge.setAttribute("aria-label", description);
|
||||||
|
badge.title = "Whether a Service Request has been created for the opportunities.";
|
||||||
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
|
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
|
||||||
else badge.title = description;
|
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
|
||||||
badge.tabIndex = 0;
|
badge.tabIndex = 0;
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
return { reset, load, createBadge };
|
return { reset, load, createBadge, getSummary };
|
||||||
}
|
}
|
||||||
|
|||||||
416
src/features/opportunities/crm-sr-form.js
Normal file
416
src/features/opportunities/crm-sr-form.js
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
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); }
|
||||||
|
function confirmPayload(payload, requestHeaders = {}) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const overlay = node("section", "opportunities-extension-action-form-modal opportunities-extension-sr-confirmation");
|
||||||
|
overlay.setAttribute("role", "dialog");
|
||||||
|
overlay.setAttribute("aria-modal", "true");
|
||||||
|
overlay.setAttribute("aria-labelledby", "opportunities-extension-sr-confirm-title");
|
||||||
|
const panel = node("div", "opportunities-extension-action-form-dialog opportunities-extension-sr-confirm-dialog");
|
||||||
|
const heading = node("header", "opportunities-extension-account-plans-header");
|
||||||
|
const title = node("h2", "", "Confirm Service Request");
|
||||||
|
title.id = "opportunities-extension-sr-confirm-title";
|
||||||
|
heading.append(title);
|
||||||
|
const body = node("div", "opportunities-extension-sr-confirm-body");
|
||||||
|
const label = node("p", "", "requestPostServiceRequest");
|
||||||
|
label.id = "opportunities-extension-sr-confirm-payload-label";
|
||||||
|
const preview = node("pre", "opportunities-extension-sr-confirm-payload", JSON.stringify(payload, null, 2));
|
||||||
|
preview.tabIndex = 0;
|
||||||
|
preview.setAttribute("aria-labelledby", label.id);
|
||||||
|
body.append(label, preview);
|
||||||
|
const headerPreviews = [];
|
||||||
|
for (const name of ["requestSrTokenCatalog", "requestPostServiceRequest"]) {
|
||||||
|
if (!requestHeaders[name]) continue;
|
||||||
|
const headerLabel = node("p", "", `${name} - Headers`);
|
||||||
|
headerLabel.id = `opportunities-extension-sr-confirm-${name}-headers`;
|
||||||
|
const headerPreview = node("pre", "opportunities-extension-sr-confirm-payload", JSON.stringify(requestHeaders[name], null, 2));
|
||||||
|
headerPreview.tabIndex = 0;
|
||||||
|
headerPreview.setAttribute("aria-labelledby", headerLabel.id);
|
||||||
|
headerPreviews.push(headerPreview);
|
||||||
|
body.append(headerLabel, headerPreview);
|
||||||
|
if (name === "requestSrTokenCatalog" && !requestHeaders[name]["x-appbuilder-client-id"]) {
|
||||||
|
body.append(node("p", "opportunities-extension-action-form-status", "x-appbuilder-client-id: not yet observed in SeaaS requests."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const footer = node("footer", "opportunities-extension-action-form-footer");
|
||||||
|
const cancel = node("button", "", "Cancel");
|
||||||
|
const confirm = node("button", "", "Confirm Save");
|
||||||
|
cancel.type = confirm.type = "button";
|
||||||
|
footer.append(cancel, confirm);
|
||||||
|
panel.append(heading, body, footer);
|
||||||
|
overlay.append(panel);
|
||||||
|
dialog.inert = true;
|
||||||
|
dialog.setAttribute("aria-hidden", "true");
|
||||||
|
modal.setAttribute("aria-modal", "false");
|
||||||
|
modal.append(overlay);
|
||||||
|
let settled = false;
|
||||||
|
function finish(confirmed) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
overlay.remove();
|
||||||
|
dialog.inert = false;
|
||||||
|
dialog.setAttribute("aria-hidden", "false");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
resolve(confirmed);
|
||||||
|
}
|
||||||
|
cancel.addEventListener("click", () => finish(false));
|
||||||
|
confirm.addEventListener("click", () => finish(true));
|
||||||
|
overlay.addEventListener("keydown", event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === "Escape") { event.preventDefault(); finish(false); }
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const controls = [preview, ...headerPreviews, cancel, confirm];
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cancel.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 || !await confirmPayload(prepared.payload, prepared.headers)) 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, 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;
|
||||||
@@ -74,6 +74,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
|
|||||||
dismiss.append(createCloseIcon());
|
dismiss.append(createCloseIcon());
|
||||||
dismiss.addEventListener("click", () => close(true));
|
dismiss.addEventListener("click", () => close(true));
|
||||||
panel.append(dismiss);
|
panel.append(dismiss);
|
||||||
|
const detailLoads = [];
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
const heading = document.createElement("div");
|
const heading = document.createElement("div");
|
||||||
@@ -90,10 +91,79 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
|
|||||||
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];
|
||||||
section.append(heading, details);
|
section.append(heading, details);
|
||||||
|
const children = document.createElement("div");
|
||||||
|
children.className = "opportunities-extension-sr-children";
|
||||||
|
children.setAttribute("aria-busy", "true");
|
||||||
|
children.setAttribute("role", "status");
|
||||||
|
children.textContent = "Loading SR details...";
|
||||||
|
section.append(children);
|
||||||
|
detailLoads.push(async () => {
|
||||||
|
try {
|
||||||
|
const records = await requestDetails(item.SrId);
|
||||||
|
if (active !== entry || entry.panel !== panel) return;
|
||||||
|
children.textContent = "";
|
||||||
|
if (!records.length) children.textContent = "No further details.";
|
||||||
|
for (const record of records) {
|
||||||
|
const child = document.createElement("section");
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "opportunities-extension-sr-number";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = "Internal SR Number:";
|
||||||
|
const link = document.createElement(record.SrNumber ? "a" : "span");
|
||||||
|
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/D";
|
||||||
|
if (record.SrNumber) {
|
||||||
|
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(record.SrNumber))}&srId=${encodeURIComponent(String(record.SrId ?? ""))}`;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
}
|
||||||
|
row.append(label, link);
|
||||||
|
if (record.SrNumber) row.append(createCopyButton(String(record.SrNumber), "SR number", "SR Number"));
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "opportunities-extension-sr-details";
|
||||||
|
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
|
||||||
|
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
|
||||||
|
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/D" : String(key === "LastUpdateDate" || key === "OpenDate" ? formatDate(record[key]) : record[key])}`).join("\n");
|
||||||
|
child.append(row, body);
|
||||||
|
children.append(child);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (active !== entry || entry.panel !== panel) return;
|
||||||
|
children.setAttribute("role", "alert");
|
||||||
|
children.textContent = error.message || "Unable to load SR details.";
|
||||||
|
} finally {
|
||||||
|
if (active === entry && entry.panel === panel) {
|
||||||
|
children.setAttribute("aria-busy", "false");
|
||||||
|
entry.position();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
panel.append(section);
|
panel.append(section);
|
||||||
});
|
});
|
||||||
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
||||||
@@ -101,6 +171,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
|
|||||||
panel.addEventListener("focusout", focusOut);
|
panel.addEventListener("focusout", focusOut);
|
||||||
host.append(panel);
|
host.append(panel);
|
||||||
active = entry;
|
active = entry;
|
||||||
|
detailLoads.forEach(load => load());
|
||||||
button.setAttribute("aria-expanded", "true");
|
button.setAttribute("aria-expanded", "true");
|
||||||
entry.position();
|
entry.position();
|
||||||
document.addEventListener("pointerdown", entry.outside, true);
|
document.addEventListener("pointerdown", entry.outside, true);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function createOpportunitiesDashboard({
|
|||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document, requestAnimationFrame, performance } = environment;
|
const { document, requestAnimationFrame, performance } = environment;
|
||||||
function renderStageDashboard(items) {
|
function renderStageDashboard(items, totalCount = items.length) {
|
||||||
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
|
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
|
||||||
|
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
@@ -23,12 +23,12 @@ function createOpportunitiesDashboard({
|
|||||||
totalCard.setAttribute("data-stage", "TOTAL");
|
totalCard.setAttribute("data-stage", "TOTAL");
|
||||||
totalLabel.textContent = "Total Opportunities";
|
totalLabel.textContent = "Total Opportunities";
|
||||||
totalValue.className = "opportunities-extension-stage-dashboard-value";
|
totalValue.className = "opportunities-extension-stage-dashboard-value";
|
||||||
totalValue.setAttribute("aria-label", "Total opportunities");
|
totalValue.setAttribute("aria-label", `${items.length} filtered opportunities out of ${totalCount} total`);
|
||||||
animateStageDashboardValue(
|
animateStageDashboardValue(
|
||||||
totalValue,
|
totalValue,
|
||||||
"TOTAL",
|
"TOTAL",
|
||||||
items.length,
|
items.length,
|
||||||
(amount) => Math.round(amount).toLocaleString()
|
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
|
||||||
);
|
);
|
||||||
totalCard.append(totalLabel, totalValue);
|
totalCard.append(totalLabel, totalValue);
|
||||||
dashboard.append(totalCard);
|
dashboard.append(totalCard);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function createOpportunitiesFilters({
|
|||||||
renderOpportunitiesTable,
|
renderOpportunitiesTable,
|
||||||
saveFilterPreferences,
|
saveFilterPreferences,
|
||||||
stringifySearchValue,
|
stringifySearchValue,
|
||||||
|
matchesActivities,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
@@ -335,12 +336,16 @@ function createOpportunitiesFilters({
|
|||||||
|
|
||||||
function getSortedOpportunities() {
|
function getSortedOpportunities() {
|
||||||
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
|
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
|
||||||
|
const allStages = STAGE_OPTIONS.every(stage => opportunitiesFilterState.selectedStages.has(stage));
|
||||||
|
const allCustomers = getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key));
|
||||||
|
const allOwners = getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key));
|
||||||
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
|
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
|
||||||
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
|
return (allStages || opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c))
|
||||||
&& matchesSelectedStatus(item.StatusCode)
|
&& matchesSelectedStatus(item.StatusCode)
|
||||||
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
|
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
|
||||||
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
|
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
|
||||||
&& matchesTableSearch(item, normalizedSearch);
|
&& matchesTableSearch(item, normalizedSearch)
|
||||||
|
&& matchesActivities(item);
|
||||||
});
|
});
|
||||||
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
||||||
|
|
||||||
@@ -391,6 +396,7 @@ function createOpportunitiesFilters({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesSelectedStatus(statusCode) {
|
function matchesSelectedStatus(statusCode) {
|
||||||
|
if (STATUS_OPTIONS.every(status => opportunitiesFilterState.selectedStatuses.has(status))) return true;
|
||||||
if (statusCode === "WON_PENDING") {
|
if (statusCode === "WON_PENDING") {
|
||||||
return opportunitiesFilterState.selectedStatuses.has("WON");
|
return opportunitiesFilterState.selectedStatuses.has("WON");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function createOpportunitiesFormatters({
|
|||||||
|
|
||||||
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
||||||
const key = getOwnerKey(item);
|
const key = getOwnerKey(item);
|
||||||
const label = getOwnerName(item);
|
const label = String(getOwnerName(item)).trim() || "-";
|
||||||
|
|
||||||
if (key && label && !optionsByKey.has(key)) {
|
if (key && label && !optionsByKey.has(key)) {
|
||||||
optionsByKey.set(key, { key, label: String(label) });
|
optionsByKey.set(key, { key, label: String(label) });
|
||||||
@@ -96,7 +96,9 @@ function createOpportunitiesFormatters({
|
|||||||
function getOwnerKey(item) {
|
function getOwnerKey(item) {
|
||||||
const owner = item.Owner;
|
const owner = item.Owner;
|
||||||
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
|
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
|
||||||
const name = getOwnerName(item);
|
const name = String(getOwnerName(item)).trim();
|
||||||
|
|
||||||
|
if (!name) return "owner:unassigned";
|
||||||
|
|
||||||
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
||||||
return `party:${partyId}`;
|
return `party:${partyId}`;
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
44
src/features/opportunities/product-activities.js
Normal file
44
src/features/opportunities/product-activities.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
function createProductActivities({ productsContentState, opportunitiesQueryState,
|
||||||
|
loadCrm, loadActions, blockActions, render }) {
|
||||||
|
let cycle = null;
|
||||||
|
function begin(requestId, token, opportunityNumbers = null) {
|
||||||
|
cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), version: opportunitiesQueryState.periodRequestVersion, completed: false };
|
||||||
|
}
|
||||||
|
function complete(requestId) {
|
||||||
|
const current = cycle;
|
||||||
|
if (!current || current.completed || current.requestId !== requestId
|
||||||
|
|| productsContentState.currentProductsRequestId !== requestId) return;
|
||||||
|
current.completed = true;
|
||||||
|
const entries = [...productsContentState.opptyProductsByNumber].filter(([number]) => !current.numbers || current.numbers.has(String(number)));
|
||||||
|
const products = entries.map(([, result]) => result).filter(result => !result.error);
|
||||||
|
if (!products.length) return;
|
||||||
|
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
|
||||||
|
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
|
||||||
|
&& opportunitiesQueryState.periodRequestVersion === current.version;
|
||||||
|
// Product cache hits do not guarantee that the corresponding activity cache exists.
|
||||||
|
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: false })).then(async success => {
|
||||||
|
if (!isActive()) return;
|
||||||
|
if (success === false) {
|
||||||
|
products.forEach(blockActions);
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pending = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const result of products) {
|
||||||
|
const items = result.items.filter(item => {
|
||||||
|
const id = String(item?.RevnId ?? "");
|
||||||
|
if (seen.has(id)) return false;
|
||||||
|
seen.add(id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: false }));
|
||||||
|
}
|
||||||
|
await Promise.allSettled(pending);
|
||||||
|
if (!isActive()) return;
|
||||||
|
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
|
||||||
|
render();
|
||||||
|
}).catch(() => { if (isActive()) { products.forEach(blockActions); render(); } });
|
||||||
|
}
|
||||||
|
return { begin, complete };
|
||||||
|
}
|
||||||
49
src/features/opportunities/refresh-all.js
Normal file
49
src/features/opportunities/refresh-all.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
|
||||||
|
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
|
||||||
|
aiSummaryState, reload, onFailure, notify, createRefreshIcon, environment }) {
|
||||||
|
let pending = null;
|
||||||
|
function refresh(period) {
|
||||||
|
if (pending) return pending;
|
||||||
|
opportunitiesQueryState.periodRequestVersion += 1;
|
||||||
|
productsContentState.currentProductsRequestId = "";
|
||||||
|
productsContentState.opptyProductsByNumber = new Map();
|
||||||
|
productsContentState.expandedProductOptyNumbers = new Set();
|
||||||
|
productsContentState.productsProgress = { state: "idle", completed: 0, total: 0, failed: 0, cacheHits: 0 };
|
||||||
|
consumptionPlansState.accountPlansRequestState = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendCache = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||||
|
aiSummaryState.aiSummariesByOptyNumber = new Map();
|
||||||
|
resetRelatedData();
|
||||||
|
pending = (async () => {
|
||||||
|
await activityCache.invalidate();
|
||||||
|
const response = await sendRuntimeMessage({ type: messageTypes.invalidateProductsCache });
|
||||||
|
if (!response?.ok) throw new Error(response?.error || "Unable to clear product cache.");
|
||||||
|
await reload(period);
|
||||||
|
})().catch(error => { onFailure(error); throw error; }).finally(() => { pending = null; });
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
function createButton(getPeriod) {
|
||||||
|
const button = environment.document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-icon-button opportunities-extension-refresh-all";
|
||||||
|
button.append(createRefreshIcon());
|
||||||
|
button.title = "Refresh all";
|
||||||
|
button.setAttribute("aria-label", "Refresh all");
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
if (button.disabled) return;
|
||||||
|
button.disabled = true;
|
||||||
|
button.title = "Refreshing...";
|
||||||
|
button.setAttribute("aria-busy", "true");
|
||||||
|
try { await refresh(getPeriod()); }
|
||||||
|
catch (error) { notify(error.message || "Unable to refresh cached data.", true); }
|
||||||
|
finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.title = "Refresh all";
|
||||||
|
button.setAttribute("aria-busy", "false");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
return { refresh, createButton };
|
||||||
|
}
|
||||||
115
src/features/opportunities/searchable-select.js
Normal file
115
src/features/opportunities/searchable-select.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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) {
|
||||||
|
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.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
|
||||||
|
index = -1;
|
||||||
|
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); });
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
return searchableSelect;
|
||||||
|
}
|
||||||
21
src/features/opportunities/sr-session-preparation.js
Normal file
21
src/features/opportunities/sr-session-preparation.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog }) {
|
||||||
|
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");
|
||||||
|
} catch {
|
||||||
|
reportProgress("error");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
return { ensure };
|
||||||
|
}
|
||||||
@@ -9,14 +9,7 @@
|
|||||||
.opportunities-extension-auth-badge { transition: none; }
|
.opportunities-extension-auth-badge { transition: none; }
|
||||||
}
|
}
|
||||||
.opportunities-extension-crm-sr-badge {
|
.opportunities-extension-crm-sr-badge {
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0 6px;
|
|
||||||
border: 1px solid #408254;
|
border: 1px solid #408254;
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: #185b30;
|
color: #185b30;
|
||||||
background: #e1f1e5;
|
background: #e1f1e5;
|
||||||
}
|
}
|
||||||
@@ -31,14 +24,15 @@
|
|||||||
|
|
||||||
.opportunities-extension-form {
|
.opportunities-extension-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: max-content minmax(110px, 1fr) minmax(110px, 1fr) minmax(145px, 180px) max-content max-content;
|
grid-template-columns: repeat(4, minmax(0, 1fr)) max-content max-content;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-form > * {
|
.opportunities-extension-form > *,
|
||||||
|
.opportunities-extension-badge-filters > section {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -47,7 +41,8 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-form > * > span:first-child {
|
.opportunities-extension-form > * > span:first-child,
|
||||||
|
.opportunities-extension-badge-filters > section > span:first-child {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
@@ -89,30 +84,77 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-form .opportunities-extension-stage-filter-button,
|
.opportunities-extension-form .opportunities-extension-stage-filter-button,
|
||||||
.opportunities-extension-form .opportunities-extension-status-filter-button {
|
.opportunities-extension-form .opportunities-extension-status-filter-button,
|
||||||
|
.opportunities-extension-form .opportunities-extension-activity-filter-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 28px;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 4px 7px;
|
padding: 4px 7px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-family: inherit;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Native select intrinsic sizing includes its longest option, not just the selection. */
|
.opportunities-extension-cell-actions > .opportunities-extension-products-count,
|
||||||
@media (min-width: 1151px) {
|
.opportunities-extension-cell-actions > .opportunities-extension-products-ramp,
|
||||||
.opportunities-extension-form > .opportunities-extension-field,
|
.opportunities-extension-cell-actions > .opportunities-extension-crm-sr-badge {
|
||||||
.opportunities-extension-form .opportunities-extension-select-wrap,
|
display: inline-flex;
|
||||||
.opportunities-extension-form .opportunities-extension-field select {
|
align-items: center;
|
||||||
width: max-content;
|
justify-content: flex-start;
|
||||||
max-width: none;
|
box-sizing: border-box;
|
||||||
}
|
flex: 0 0 auto;
|
||||||
|
gap: 4px;
|
||||||
|
height: 20px;
|
||||||
|
min-height: 20px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opportunities-extension-cell-actions .opportunities-extension-badge-icon,
|
||||||
|
.opportunities-extension-activity-filter-button .opportunities-extension-badge-icon {
|
||||||
|
display: block;
|
||||||
|
order: -1;
|
||||||
|
flex: 0 0 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2.25;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1150px) {
|
@media (max-width: 1150px) {
|
||||||
.opportunities-extension-form {
|
.opportunities-extension-form {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.opportunities-extension-form > .opportunities-extension-status-filter,
|
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||||
.opportunities-extension-form > .opportunities-extension-stage-filter {
|
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,16 +167,253 @@
|
|||||||
.opportunities-extension-form .opportunities-extension-status-filter-controls {
|
.opportunities-extension-form .opportunities-extension-status-filter-controls {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.opportunities-extension-form > .opportunities-extension-status-filter,
|
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||||
.opportunities-extension-form > .opportunities-extension-stage-filter {
|
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: auto auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
|
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
|
||||||
.opportunities-extension-form > .opportunities-extension-status-filter,
|
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||||
.opportunities-extension-form > .opportunities-extension-stage-filter { grid-column: auto; }
|
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter { grid-column: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.opportunities-extension-badge-filters { display: contents; }
|
||||||
|
.opportunities-extension-activity-filter { display: grid; }
|
||||||
|
.opportunities-extension-activity-filter[hidden] { display: none; }
|
||||||
|
.opportunities-extension-activity-filter-controls { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; }
|
||||||
|
/* Flexible fields share the space left by the four compact badge groups. */
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr)) repeat(4, max-content);
|
||||||
|
column-gap: 8px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-activity-filter-button {
|
||||||
|
min-width: 32px;
|
||||||
|
gap: 4px;
|
||||||
|
color: #185b30;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.opportunities-extension-activity-filter-button[data-value="missing"] { color: #9f201b; }
|
||||||
|
.opportunities-extension-activity-filter-button[data-value="under4"] { color: #005f7d; }
|
||||||
|
.opportunities-extension-activity-filter-button[aria-pressed="true"] { color: #fff; background: #25653a; border-color: #25653a; }
|
||||||
|
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="missing"] { background: #a12d27; border-color: #a12d27; }
|
||||||
|
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="under4"] { background: #006b86; border-color: #006b86; }
|
||||||
|
.opportunities-extension-activity-filter-button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter { color: #eeeae6; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button { color: #b8e4c6; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button:not([aria-pressed="true"]) { background: #333130; border-color: #6a6560; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="missing"] { color: #ffd9d5; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="under4"] { color: #b5e9fc; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[aria-pressed="true"] { border-color: currentColor; }
|
||||||
|
@media (max-width: 1350px) {
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: start;
|
||||||
|
gap: 12px 20px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters > section {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.opportunities-extension-activity-filter-controls { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.opportunities-extension-add-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0 2px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.opportunities-extension-add-action:hover { background: rgba(127, 127, 127, .2); }
|
||||||
|
.opportunities-extension-add-action:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-action-form-modal {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, .42);
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-dialog {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(560px, 100%);
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
color: #312d2a;
|
||||||
|
border: 1px solid #b9b4ae;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, .25);
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-action-form-context {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content minmax(0, 1fr);
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-context dt { font-weight: 600; }
|
||||||
|
.opportunities-extension-action-form-context dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-action-form-field { display: grid; gap: 6px; min-width: 0; font-size: 13px; }
|
||||||
|
.opportunities-extension-action-form-field[hidden] { display: none; }
|
||||||
|
.opportunities-extension-action-form-status { margin: 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-action-form-status[role="alert"] { color: #a12d27; }
|
||||||
|
.opportunities-extension-action-form-wide,
|
||||||
|
.opportunities-extension-action-form-footer { grid-column: 1 / -1; }
|
||||||
|
.opportunities-extension-action-form-field input,
|
||||||
|
.opportunities-extension-action-form-field select,
|
||||||
|
.opportunities-extension-action-form-field textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #8f8983;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 20px;
|
||||||
|
color: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
|
||||||
|
.opportunities-extension-action-form-field select { height: 38px; }
|
||||||
|
.opportunities-extension-action-form-field select[hidden] { display: none; }
|
||||||
|
.opportunities-extension-sr-form-dialog { width: min(900px, 100%); }
|
||||||
|
.opportunities-extension-sr-confirmation { z-index: 6; }
|
||||||
|
.opportunities-extension-sr-confirm-dialog { width: min(800px, 100%); display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.opportunities-extension-sr-confirm-dialog > header,
|
||||||
|
.opportunities-extension-sr-confirm-dialog > footer { flex-shrink: 0; }
|
||||||
|
.opportunities-extension-sr-confirm-dialog > footer { padding: 16px 20px; }
|
||||||
|
.opportunities-extension-sr-confirm-body { min-height: 0; overflow: auto; padding: 0 20px; }
|
||||||
|
.opportunities-extension-sr-confirm-body > p { font-size: 13px; font-weight: 600; }
|
||||||
|
.opportunities-extension-sr-confirm-payload { margin: 0; padding: 16px; border: 1px solid #b9b4ae; border-radius: 4px; background: #f6f5f4; color: #312d2a; font: 13px/1.6 Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-sr-confirmation :focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-confirm-payload { background: #211f1d; color: #eeeae6; border-color: #756f68; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-confirmation :focus-visible { outline-color: #80d6e8; }
|
||||||
|
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 20px 24px; }
|
||||||
|
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { gap: 20px 24px; padding: 24px; align-items: start; }
|
||||||
|
.opportunities-extension-sr-form-dialog .opportunities-extension-action-form-field { gap: 8px; }
|
||||||
|
.opportunities-extension-sr-form-dialog textarea { min-height: 112px; }
|
||||||
|
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-option { padding: 12px 0; gap: 10px; }
|
||||||
|
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-abstract { font-size: 13px; line-height: 20px; }
|
||||||
|
@media (min-width: 1000px) {
|
||||||
|
.opportunities-extension-sr-form-dialog { width: min(1120px, 100%); }
|
||||||
|
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px 24px; }
|
||||||
|
.opportunities-extension-sr-services { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 28px; align-items: start; }
|
||||||
|
.opportunities-extension-sr-services .opportunities-extension-sr-service-option { margin: 0; border-top: 1px solid #aaa6a040; }
|
||||||
|
.opportunities-extension-sr-services .opportunities-extension-sr-service-option:nth-child(-n+2) { border-top: 0; }
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 16px; }
|
||||||
|
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); gap: 18px; padding: 16px; }
|
||||||
|
}
|
||||||
|
.opportunities-extension-sr-lookup { min-width: 0; padding: 0; margin: 0; border: 0; font-size: 13px; }
|
||||||
|
.opportunities-extension-sr-lookup legend { padding: 0 0 8px; font-weight: 600; }
|
||||||
|
.opportunities-extension-sr-lookup[aria-busy="true"] > p { opacity: .65; }
|
||||||
|
.opportunities-extension-sr-service-option { display: flex; align-items: flex-start; gap: 8px; padding: 9px 0; cursor: pointer; }
|
||||||
|
.opportunities-extension-sr-service-option + .opportunities-extension-sr-service-option { border-top: 1px solid #aaa6a040; }
|
||||||
|
.opportunities-extension-sr-service-option input { flex: 0 0 16px; width: 16px; height: 16px; margin: 2px 0 0; accent-color: #00758f; }
|
||||||
|
.opportunities-extension-sr-service-option > span { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-sr-service-name { display: block; font-weight: 600; line-height: 20px; }
|
||||||
|
.opportunities-extension-sr-service-abstract { display: block; margin-top: 3px; color: #625d58; font-size: 12px; line-height: 18px; white-space: pre-wrap; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-service-abstract { color: #c7c2bc; }
|
||||||
|
.opportunities-extension-sr-lookup-retry { color: inherit; background: transparent; border: 1px solid #8f8983; border-radius: 4px; padding: 5px 10px; cursor: pointer; }
|
||||||
|
.opportunities-extension-sr-lookup-retry[hidden] { display: none; }
|
||||||
|
.opportunities-extension-action-form [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-copy-toast { z-index: 20; }
|
||||||
|
.opportunities-extension-action-combobox { position: relative; min-width: 0; }
|
||||||
|
.opportunities-extension-action-combobox input { height: 38px; }
|
||||||
|
.opportunities-extension-action-options {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
border: 1px solid #8f8983;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, .2);
|
||||||
|
background: #fff;
|
||||||
|
color: #292725;
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-options[data-placement="above"] { top: auto; bottom: calc(100% + 4px); }
|
||||||
|
.opportunities-extension-action-options[hidden] { display: none; }
|
||||||
|
.opportunities-extension-action-option { padding: 8px; line-height: 20px; overflow-wrap: anywhere; cursor: pointer; }
|
||||||
|
.opportunities-extension-action-option:hover,
|
||||||
|
.opportunities-extension-action-option[aria-selected="true"] { background: #e3f2f5; color: #005b70; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-options { background: #211f1d; color: #eeeae6; border-color: #918a82; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option:hover,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option[aria-selected="true"] { background: #174955; color: #c9f4ff; }
|
||||||
|
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
|
||||||
|
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled {
|
||||||
|
animation: opportunities-action-options-loading 1s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes opportunities-action-options-loading { from { opacity: .45; } to { opacity: .85; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled,
|
||||||
|
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: none; }
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.opportunities-extension-action-form-footer button {
|
||||||
|
border: 1px solid #8f8983;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-footer button:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
.opportunities-extension-action-form :focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-dialog {
|
||||||
|
color-scheme: dark;
|
||||||
|
background: #292725;
|
||||||
|
color: #eeeae6;
|
||||||
|
border-color: #756f68;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field input,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field select,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field textarea { background: #211f1d; border-color: #918a82; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-status[role="alert"] { color: #ffd9d5; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form :focus-visible { outline-color: #80d6e8; }
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep layout rules on the outer table, never on expanded product tables. */
|
/* Keep layout rules on the outer table, never on expanded product tables. */
|
||||||
@@ -232,18 +511,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-products-ramp {
|
.opportunities-extension-products-ramp {
|
||||||
display: inline-block;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0 6px;
|
|
||||||
border: 1px solid #bba5cf;
|
border: 1px solid #bba5cf;
|
||||||
border-radius: 8px;
|
|
||||||
background: #f0e8f7;
|
background: #f0e8f7;
|
||||||
color: #63358a;
|
color: #63358a;
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 16px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
@@ -302,6 +574,11 @@
|
|||||||
.opportunities-extension-sr-number { display: flex; align-items: center; gap: 8px; padding-right: 30px; font-weight: 600; }
|
.opportunities-extension-sr-number { display: flex; align-items: center; gap: 8px; padding-right: 30px; font-weight: 600; }
|
||||||
.opportunities-extension-sr-popover a { color: #00677d; text-decoration: underline; }
|
.opportunities-extension-sr-popover a { color: #00677d; text-decoration: underline; }
|
||||||
.opportunities-extension-sr-details { white-space: pre-wrap; margin-top: 6px; }
|
.opportunities-extension-sr-details { white-space: pre-wrap; margin-top: 6px; }
|
||||||
|
.opportunities-extension-sr-children { margin: 10px 0 0 8px; padding-left: 10px; border-left: 2px solid #8c969a; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-sr-children[aria-busy="true"] { min-height: 28px; animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
|
||||||
|
.opportunities-extension-sr-children[role="alert"] { color: #a12d27; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-children[role="alert"] { color: #ffd9d5; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .opportunities-extension-sr-children[aria-busy="true"] { animation: none; } }
|
||||||
.opportunities-extension-sr-dismiss {
|
.opportunities-extension-sr-dismiss {
|
||||||
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
|
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
|
||||||
display: grid; place-items: center; padding: 4px; border: 0;
|
display: grid; place-items: center; padding: 4px; border: 0;
|
||||||
@@ -311,3 +588,43 @@
|
|||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover { background: #292827; color: #f1efec; border-color: #77716b; }
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover { background: #292827; color: #f1efec; border-color: #77716b; }
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover section + section { border-color: #5f5953; }
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover section + section { border-color: #5f5953; }
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
|
||||||
|
.opportunities-extension-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.opportunities-extension-title-row h1 {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.opportunities-extension-title-row .opportunities-extension-subtitle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.opportunities-extension-title-row .opportunities-extension-subtitle-row > span {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 22px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-title-row + .opportunities-extension-header-actions { flex-shrink: 0; }
|
||||||
|
.opportunities-extension-refresh-all:disabled { opacity: .65; cursor: wait; }
|
||||||
|
.opportunities-extension-refresh-all:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: opportunities-refresh-spin 1s linear infinite; }
|
||||||
|
@keyframes opportunities-refresh-spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; }
|
||||||
|
}
|
||||||
|
.opportunities-extension-option-description { display: block; font-size: 12px; line-height: 1.4; margin-top: 4px; white-space: normal; overflow-wrap: anywhere; opacity: .85; }
|
||||||
|
.opportunities-extension-action-form-dialog.opportunities-extension-internal-sr-dialog { width: min(760px, 100%); }
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ function createOpportunitiesTable({
|
|||||||
productsContentState,
|
productsContentState,
|
||||||
renderCustomerFilter,
|
renderCustomerFilter,
|
||||||
renderOwnerFilter,
|
renderOwnerFilter,
|
||||||
|
renderActivityFilters,
|
||||||
renderStageDashboard,
|
renderStageDashboard,
|
||||||
|
renderCalendar,
|
||||||
resetCustomerFilter,
|
resetCustomerFilter,
|
||||||
resetOwnerFilter,
|
resetOwnerFilter,
|
||||||
environment
|
environment
|
||||||
@@ -47,6 +49,7 @@ function createOpportunitiesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderOpportunitiesTable() {
|
function renderOpportunitiesTable() {
|
||||||
|
renderActivityFilters();
|
||||||
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
|
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
|
||||||
|
|
||||||
if (!table) {
|
if (!table) {
|
||||||
@@ -99,7 +102,8 @@ function createOpportunitiesTable({
|
|||||||
|
|
||||||
const tableBody = document.createElement("tbody");
|
const tableBody = document.createElement("tbody");
|
||||||
const items = getSortedOpportunities();
|
const items = getSortedOpportunities();
|
||||||
renderStageDashboard(items);
|
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");
|
||||||
|
|||||||
304
src/features/opportunities/workload-action-form.js
Normal file
304
src/features/opportunities/workload-action-form.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
|
||||||
|
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, searchableSelect, environment }) {
|
||||||
|
const { document } = environment;
|
||||||
|
let active = null;
|
||||||
|
function element(tag, className, text) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
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(optyNumber) {
|
||||||
|
const button = element("button", "opportunities-extension-add-action", "+");
|
||||||
|
button.type = "button";
|
||||||
|
button.title = "Add Action";
|
||||||
|
button.setAttribute("aria-label", `Add Action for ${optyNumber}`);
|
||||||
|
button.setAttribute("aria-haspopup", "dialog");
|
||||||
|
button.addEventListener("click", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
open(optyNumber, button);
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
function open(optyNumber, trigger) {
|
||||||
|
if (!allowsCrmActivities() || !isMissingAction(optyNumber)) return;
|
||||||
|
const host = document.getElementById(MODAL_ID);
|
||||||
|
if (!host || host.hidden) return;
|
||||||
|
close(false);
|
||||||
|
const modal = element("section", "opportunities-extension-action-form-modal");
|
||||||
|
modal.setAttribute("role", "dialog");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
modal.setAttribute("aria-labelledby", "opportunities-extension-action-form-title");
|
||||||
|
const dialog = element("div", "opportunities-extension-action-form-dialog");
|
||||||
|
const header = element("header", "opportunities-extension-account-plans-header");
|
||||||
|
const heading = element("div", "opportunities-extension-action-form-heading");
|
||||||
|
const title = element("h2", "", "Add Action");
|
||||||
|
title.id = "opportunities-extension-action-form-title";
|
||||||
|
heading.append(title, element("p", "", String(optyNumber)));
|
||||||
|
const opportunity = getOpportunity(optyNumber);
|
||||||
|
const details = element("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(element("dt", "", label), element("dd", "", value == null || value === "" ? "-" : String(value)));
|
||||||
|
}
|
||||||
|
heading.append(details);
|
||||||
|
const dismiss = element("button", "opportunities-extension-icon-button");
|
||||||
|
dismiss.type = "button";
|
||||||
|
dismiss.title = "Close";
|
||||||
|
dismiss.setAttribute("aria-label", "Close Action form");
|
||||||
|
dismiss.append(createCloseIcon());
|
||||||
|
dismiss.addEventListener("click", () => close());
|
||||||
|
header.append(heading, dismiss);
|
||||||
|
const form = element("form", "opportunities-extension-action-form");
|
||||||
|
form.noValidate = true;
|
||||||
|
const workloads = [...new Map(getProducts(optyNumber).filter(product => /^\d+$/.test(String(product?.RevnId ?? "")))
|
||||||
|
.map(product => [String(product.RevnId), product])).entries()];
|
||||||
|
const workloadField = element("label", "opportunities-extension-action-form-field opportunities-extension-action-form-wide");
|
||||||
|
const workload = element("select");
|
||||||
|
workload.id = "opportunities-extension-action-form-workload";
|
||||||
|
workloadField.htmlFor = workload.id;
|
||||||
|
function option(select, value, label) {
|
||||||
|
const item = element("option", "", label);
|
||||||
|
item.value = value;
|
||||||
|
select.append(item);
|
||||||
|
}
|
||||||
|
option(workload, "", "Select workload");
|
||||||
|
for (const [id, product] of workloads) option(workload, id, `${product.WorkloadName_c || product.ProdGroupName || "Workload"} (${id})`);
|
||||||
|
workload.value = workloads.length === 1 ? workloads[0][0] : "";
|
||||||
|
workloadField.hidden = workloads.length <= 1;
|
||||||
|
workloadField.append(element("span", "", "Workload"), workload);
|
||||||
|
const status = element("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
form.append(workloadField, status);
|
||||||
|
const searchable = new Map();
|
||||||
|
searchable.set(workload, searchableSelect(workload, workloadField, () => loadOptions()));
|
||||||
|
const fields = [];
|
||||||
|
const selects = [];
|
||||||
|
const lookupKeys = { role: "WWB_ACTION_ROLE", action: "WWB_ACTION_NAME", team: "WWB_ACTION_TEAM",
|
||||||
|
status: "WWB_ACTION_STATUS", complexity: "WWB_ACTION_COMPLEXITY" };
|
||||||
|
for (const [name, label, type] of [
|
||||||
|
["owner", "Owner", "select"], ["role", "Role", "select"], ["action", "Action", "select"],
|
||||||
|
["team", "Team", "select"], ["startDate", "Start date", "date"],
|
||||||
|
["endDate", "End Date", "date"], ["status", "Status", "select"],
|
||||||
|
["complexity", "Complexity", "select"], ["notes", "Notes", "textarea"]
|
||||||
|
]) {
|
||||||
|
const field = element("label", "opportunities-extension-action-form-field");
|
||||||
|
const input = element(type === "select" || type === "textarea" ? type : "input");
|
||||||
|
if (type === "select") selects.push({ input, label, name });
|
||||||
|
else if (type === "textarea") input.rows = 5;
|
||||||
|
else input.type = type;
|
||||||
|
input.disabled = true;
|
||||||
|
input.required = name !== "notes";
|
||||||
|
input.setAttribute("aria-required", String(input.required));
|
||||||
|
input.name = name;
|
||||||
|
input.id = `opportunities-extension-action-form-${name}`;
|
||||||
|
field.htmlFor = input.id;
|
||||||
|
if (["notes", "team", "action"].includes(name)) field.className += " opportunities-extension-action-form-wide";
|
||||||
|
field.append(element("span", "", `${label} (${input.required ? "required" : "optional"})`), input);
|
||||||
|
if (type === "select") searchable.set(input, searchableSelect(input, field));
|
||||||
|
form.append(field);
|
||||||
|
fields.push(input);
|
||||||
|
}
|
||||||
|
const footer = element("footer", "opportunities-extension-action-form-footer");
|
||||||
|
const save = element("button", "opportunities-extension-action-form-save", "Save");
|
||||||
|
save.type = "submit";
|
||||||
|
save.disabled = true;
|
||||||
|
save.title = "Save Action";
|
||||||
|
const retry = element("button", "", "Retry");
|
||||||
|
retry.type = "button";
|
||||||
|
retry.hidden = true;
|
||||||
|
const cancel = element("button", "", "Cancel");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.addEventListener("click", () => close());
|
||||||
|
footer.append(retry, save, cancel);
|
||||||
|
form.append(footer);
|
||||||
|
dialog.append(header, form);
|
||||||
|
modal.append(dialog);
|
||||||
|
host.append(modal);
|
||||||
|
const view = { modal, trigger, generation: 0, lookup: null, loadingId: null };
|
||||||
|
active = view;
|
||||||
|
const isOpen = () => active === view && allowsCrmActivities();
|
||||||
|
let ready = false;
|
||||||
|
function validate() {
|
||||||
|
const invalid = [];
|
||||||
|
const value = name => String(fields.find(field => field.name === name)?.value || "").trim();
|
||||||
|
for (const field of fields) {
|
||||||
|
let valid = !field.required || !!value(field.name);
|
||||||
|
if (field.required && searchable.has(field)) valid = Array.from(field.children).some(option => option.value && option.value === field.value);
|
||||||
|
if (field.type === "date") {
|
||||||
|
const date = value(field.name);
|
||||||
|
valid = /^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(Date.parse(`${date}T00:00:00.000Z`));
|
||||||
|
if (valid) valid = new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) === date;
|
||||||
|
if (field.name === "endDate" && value("startDate") > date) valid = false;
|
||||||
|
}
|
||||||
|
const control = searchable.get(field)?.input || field;
|
||||||
|
control.setAttribute("aria-invalid", String(!valid));
|
||||||
|
if (!valid) invalid.push(control);
|
||||||
|
}
|
||||||
|
return { invalid, value };
|
||||||
|
}
|
||||||
|
form.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!ready || !isOpen() || view.saving) return;
|
||||||
|
const { invalid, value } = validate();
|
||||||
|
if (invalid.length) {
|
||||||
|
status.hidden = false;
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = "Complete the highlighted required fields. End date must be on or after start date.";
|
||||||
|
invalid[0].focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = getCurrentUserId();
|
||||||
|
if (!userId) { notify("Unable to save: current user is unavailable.", true); return; }
|
||||||
|
const payload = { parentType: "WORKLOAD", parentId: workload.value, action: {
|
||||||
|
ownerEmail: value("owner"), role: value("role"), name: value("action"), team: value("team"), tags: "",
|
||||||
|
startDate: `${value("startDate")}T00:00:00.000Z`, endDate: `${value("endDate")}T00:00:00.000Z`,
|
||||||
|
status: value("status"), complexity: value("complexity"), notes: value("notes") ? [value("notes")] : [],
|
||||||
|
objective: "", createdBy: userId, updatedBy: userId
|
||||||
|
} };
|
||||||
|
view.saving = true;
|
||||||
|
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = true;
|
||||||
|
fields.forEach(field => { field.disabled = true; });
|
||||||
|
searchable.forEach(control => control.sync());
|
||||||
|
save.textContent = "Saving...";
|
||||||
|
form.setAttribute("aria-busy", "true");
|
||||||
|
status.hidden = true;
|
||||||
|
try {
|
||||||
|
const result = await saveAction(payload, isOpen);
|
||||||
|
if (!result) return;
|
||||||
|
view.saving = false;
|
||||||
|
close();
|
||||||
|
try {
|
||||||
|
await onActionSaved(optyNumber, payload.parentId, result.result, payload.action);
|
||||||
|
} catch {
|
||||||
|
notify("Action saved, but the local badge could not be updated.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify("Action saved successfully.");
|
||||||
|
} catch (error) {
|
||||||
|
status.hidden = false;
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = error.message || "Unable to save Action.";
|
||||||
|
notify(status.textContent, true);
|
||||||
|
} finally {
|
||||||
|
view.saving = false;
|
||||||
|
if (active === view) {
|
||||||
|
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = false;
|
||||||
|
fields.forEach(field => { field.disabled = false; });
|
||||||
|
searchable.forEach(control => control.sync());
|
||||||
|
save.textContent = "Save";
|
||||||
|
form.setAttribute("aria-busy", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const settled = promise => promise.then(result => ({ result }), error => ({ error }));
|
||||||
|
async function loadOptions(refresh = false) {
|
||||||
|
const id = workload.value;
|
||||||
|
if (!isOpen() || (view.loadingId === id && !refresh)) return;
|
||||||
|
const generation = ++view.generation;
|
||||||
|
const isCurrent = () => isOpen() && view.generation === generation;
|
||||||
|
ready = false;
|
||||||
|
save.disabled = true;
|
||||||
|
retry.hidden = true;
|
||||||
|
status.hidden = false;
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
fields.forEach(input => { input.disabled = true; });
|
||||||
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Loading..."); });
|
||||||
|
const syncSearch = () => selects.forEach(({ input }) => searchable.get(input).sync());
|
||||||
|
syncSearch();
|
||||||
|
if (!workloads.length) {
|
||||||
|
form.setAttribute("data-state", "error");
|
||||||
|
form.setAttribute("aria-busy", "false");
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = "No product with a valid RevnId is available for this opportunity.";
|
||||||
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
|
||||||
|
syncSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!view.lookup || refresh) view.lookup = settled(requestLookup(isOpen));
|
||||||
|
if (!workloads.some(([value]) => value === id)) {
|
||||||
|
view.loadingId = null;
|
||||||
|
form.setAttribute("data-state", "waiting");
|
||||||
|
form.setAttribute("aria-busy", "false");
|
||||||
|
status.textContent = "Select a workload to load Action options.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view.loadingId = id;
|
||||||
|
form.setAttribute("data-state", "loading");
|
||||||
|
form.setAttribute("aria-busy", "true");
|
||||||
|
status.textContent = "Loading Action options...";
|
||||||
|
try {
|
||||||
|
const [lookup, team] = await Promise.all([view.lookup, settled(requestTeamMembers(id, isCurrent))]);
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
if (lookup.error || team.error) throw lookup.error || team.error;
|
||||||
|
const values = selects.map(({ name }) => name === "owner"
|
||||||
|
? team.result.members.map(({ email }) => ({ code: email, name: email }))
|
||||||
|
: lookup.result.lookups[lookupKeys[name]]);
|
||||||
|
const defaults = { owner: getCurrentUserId(), role: "Cloud Architect",
|
||||||
|
action: "Success Plan & Consumption review", status: "Complete" };
|
||||||
|
const normalize = value => String(value ?? "").trim().toLowerCase();
|
||||||
|
for (const [index, { input, label, name }] of selects.entries()) {
|
||||||
|
input.textContent = "";
|
||||||
|
option(input, "", values[index].length ? `Select ${label.toLowerCase()}` : "No options available");
|
||||||
|
for (const item of values[index]) option(input, item.code, item.name);
|
||||||
|
const match = defaults[name] && values[index].find(item =>
|
||||||
|
normalize(name === "owner" ? item.code : item.name) === normalize(defaults[name]));
|
||||||
|
input.value = match ? match.code : "";
|
||||||
|
}
|
||||||
|
fields.forEach(input => { input.disabled = false; });
|
||||||
|
selects.forEach(({ input }, index) => { input.disabled = !values[index].length; });
|
||||||
|
form.setAttribute("data-state", "ready");
|
||||||
|
ready = true;
|
||||||
|
save.disabled = false;
|
||||||
|
status.hidden = true;
|
||||||
|
syncSearch();
|
||||||
|
if (document.activeElement === dismiss) {
|
||||||
|
const first = fields.find(field => !field.disabled);
|
||||||
|
(searchable.get(first)?.input || first)?.focus();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
form.setAttribute("data-state", "error");
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = error.message || "Unable to load Action options.";
|
||||||
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
|
||||||
|
syncSearch();
|
||||||
|
retry.hidden = false;
|
||||||
|
} finally {
|
||||||
|
if (isCurrent()) { view.loadingId = null; form.setAttribute("aria-busy", "false"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retry.addEventListener("click", () => { if (!view.loadingId) loadOptions(true); });
|
||||||
|
workload.addEventListener("change", () => loadOptions());
|
||||||
|
modal.addEventListener("click", event => { if (event.target === modal) close(); });
|
||||||
|
modal.addEventListener("keydown", event => {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = [dismiss, ...(!workloadField.hidden ? [workload] : []), ...fields, retry, save, cancel]
|
||||||
|
.map(control => searchable.get(control)?.input || control)
|
||||||
|
.filter(control => !control.disabled && !control.hidden);
|
||||||
|
const index = controls.indexOf(document.activeElement);
|
||||||
|
if (!controls.length) { event.preventDefault(); return; }
|
||||||
|
if (index === -1 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
|
||||||
|
event.preventDefault();
|
||||||
|
controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!workloadField.hidden) searchable.get(workload).input.focus();
|
||||||
|
else dismiss.focus();
|
||||||
|
loadOptions();
|
||||||
|
}
|
||||||
|
return { createButton, open, close };
|
||||||
|
}
|
||||||
17
src/features/opportunities/workload-action-options.js
Normal file
17
src/features/opportunities/workload-action-options.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
function createWorkloadActionOptions({ requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
||||||
|
async function request(type, workloadId, isCurrent, payload) {
|
||||||
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
|
if (!isCurrent()) return null;
|
||||||
|
const authorization = authorizations?.workloadActions;
|
||||||
|
if (!authorization?.value) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await sendRuntimeMessage({ type, ...(workloadId === undefined ? {} : { workloadId }), ...(payload ? { payload } : {}), authorization });
|
||||||
|
appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load Action options.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
save: (payload, isCurrent) => request(messageTypes.requestMgmtAction, undefined, isCurrent, payload),
|
||||||
|
requestTeamMembers: (id, isCurrent) => request(messageTypes.requestActionTeamMembers, id, isCurrent),
|
||||||
|
requestLookup: isCurrent => request(messageTypes.requestActionFindLookup, undefined, isCurrent)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,31 +1,59 @@
|
|||||||
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||||
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
|
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, createActionButton, closeActionForm, setBadgeContent, environment }) {
|
||||||
let session = newSession();
|
let session = newSession();
|
||||||
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; }
|
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; }
|
||||||
|
function updateProgress(current) {
|
||||||
|
if (current !== session || !current.enabled || !current.progress.size) return;
|
||||||
|
const records = [...current.progress];
|
||||||
|
const completed = records.filter(record => record.status !== "loading").length;
|
||||||
|
const failed = records.filter(record => record.status === "error").length;
|
||||||
|
reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length);
|
||||||
|
}
|
||||||
function reset(enabled = true) {
|
function reset(enabled = true) {
|
||||||
|
closeActionForm(false);
|
||||||
session.enabled = false;
|
session.enabled = false;
|
||||||
|
for (const job of session.queue) job.record.resolveCompletion();
|
||||||
session.queue.length = 0;
|
session.queue.length = 0;
|
||||||
session = newSession();
|
session = newSession();
|
||||||
session.enabled = enabled;
|
session.enabled = enabled;
|
||||||
|
reportProgress("idle");
|
||||||
}
|
}
|
||||||
function idsFor(products) {
|
function idsFor(products) {
|
||||||
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
|
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
|
||||||
}
|
}
|
||||||
function loadProducts(products) {
|
function loadProducts(products, refresh = false, cacheOptions = null) {
|
||||||
if (!session.enabled || !allowsCrmActivities()) return;
|
if (!session.enabled || !allowsCrmActivities()) return;
|
||||||
|
const pending = [];
|
||||||
|
if ([...session.progress].every(record => record.status !== "loading")) session.progress.clear();
|
||||||
for (const revnId of idsFor(products)) {
|
for (const revnId of idsFor(products)) {
|
||||||
if (session.records.has(revnId)) continue;
|
if (!refresh && session.records.has(revnId)) {
|
||||||
const record = { status: "loading", actions: [], hasNext: false };
|
pending.push(session.records.get(revnId).completion);
|
||||||
session.records.set(revnId, record);
|
continue;
|
||||||
session.queue.push({ revnId, record });
|
|
||||||
}
|
}
|
||||||
|
session.progress.delete(session.records.get(revnId));
|
||||||
|
const record = { status: "loading", actions: [], hasNext: false };
|
||||||
|
record.completion = new Promise(resolve => { record.resolveCompletion = resolve; });
|
||||||
|
pending.push(record.completion);
|
||||||
|
session.records.set(revnId, record);
|
||||||
|
if (!cacheOptions) session.progress.add(record);
|
||||||
|
session.queue.push({ revnId, record, cacheOptions });
|
||||||
|
}
|
||||||
|
updateProgress(session);
|
||||||
pump(session);
|
pump(session);
|
||||||
|
return Promise.all(pending);
|
||||||
}
|
}
|
||||||
function reopen() {
|
function reopen() {
|
||||||
reset();
|
reset();
|
||||||
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
|
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
|
||||||
queueProductsTableRender();
|
queueProductsTableRender();
|
||||||
}
|
}
|
||||||
|
function blockProducts(products) {
|
||||||
|
if (!allowsCrmActivities()) return;
|
||||||
|
for (const id of idsFor(products)) {
|
||||||
|
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
|
||||||
|
}
|
||||||
|
updateProgress(session);
|
||||||
|
}
|
||||||
function pump(current) {
|
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) {
|
||||||
const job = current.queue.shift();
|
const job = current.queue.shift();
|
||||||
@@ -33,26 +61,48 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
|||||||
run(current, job).finally(() => { current.running -= 1; pump(current); });
|
run(current, job).finally(() => { current.running -= 1; pump(current); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function run(current, { revnId, record }) {
|
async function run(current, { revnId, record, cacheOptions }) {
|
||||||
try {
|
try {
|
||||||
|
const cached = cacheOptions ? await activityCache.read("actions", revnId, cacheOptions.cachedAt) : null;
|
||||||
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
|
if (cached) {
|
||||||
|
record.actions = cached.items;
|
||||||
|
record.hasNext = cached.hasNext;
|
||||||
|
record.status = "ready";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cacheOptions?.cacheOnly) { record.status = "error"; record.error = "No matching activity cache for this product update."; return; }
|
||||||
|
current.progress.add(record);
|
||||||
|
updateProgress(current);
|
||||||
|
let result;
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||||
|
try {
|
||||||
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
const authorizations = await requestConsumerTokenServiceOnce();
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
|
result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
|
||||||
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
appendBackgroundRequestLog(result?.debugRequests);
|
appendBackgroundRequestLog(result?.debugRequests);
|
||||||
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
|
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 5) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
record.actions = result.actions;
|
record.actions = result.actions;
|
||||||
record.hasNext = result.hasNext;
|
record.hasNext = result.hasNext;
|
||||||
record.status = "ready";
|
record.status = "ready";
|
||||||
|
if (cacheOptions) await activityCache.write("actions", revnId, cacheOptions.cachedAt, result.actions, result.hasNext);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!current.enabled || current !== session) return;
|
if (!current.enabled || current !== session) return;
|
||||||
record.status = "error";
|
record.status = "error";
|
||||||
record.error = error.message || "Unable to load workload actions.";
|
record.error = error.message || "Unable to load workload actions.";
|
||||||
} finally {
|
} finally {
|
||||||
if (current.enabled && current === session) queueProductsTableRender();
|
record.resolveCompletion();
|
||||||
|
if (current.enabled && current === session) { updateProgress(current); queueProductsTableRender(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function createBadge(optyNumber) {
|
function getSummary(optyNumber) {
|
||||||
if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null;
|
if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null;
|
||||||
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
|
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
|
||||||
if (!products) return null;
|
if (!products) return null;
|
||||||
@@ -63,12 +113,47 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
|||||||
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|
||||||
|| records.some(record => record?.status === "error" || record?.hasNext);
|
|| records.some(record => record?.status === "error" || record?.hasNext);
|
||||||
const principal = authSessionState.principal;
|
const principal = authSessionState.principal;
|
||||||
const owned = Boolean(principal) && actions.some(action => typeof action.owner === "string" && action.owner.trim() === principal);
|
const owned = Boolean(principal?.trim()) && actions.some(action => typeof action.owner === "string"
|
||||||
|
&& action.owner.trim().toLowerCase() === principal.trim().toLowerCase());
|
||||||
const state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
|
const state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
|
||||||
|
return { state, actions, records, principal, pending, uncertain };
|
||||||
|
}
|
||||||
|
async function applySavedAction(optyNumber, revnId, response, submittedAction = {}) {
|
||||||
|
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
|
||||||
|
const id = String(revnId);
|
||||||
|
if (!session.enabled || !allowsCrmActivities() || !idsFor(products).includes(id)) return false;
|
||||||
|
const returned = Array.isArray(response) ? response : Array.isArray(response?.actions) ? response.actions : [response?.action || response];
|
||||||
|
const saved = returned.filter(action => action && typeof action === "object" && action.id != null
|
||||||
|
&& (action.revenueId == null || String(action.revenueId) === id));
|
||||||
|
if (!saved.length) return false;
|
||||||
|
const previous = session.records.get(id);
|
||||||
|
const actions = [...(previous?.actions || [])];
|
||||||
|
for (const action of saved) {
|
||||||
|
// The POST can return a display name in owner, unlike the workload GET.
|
||||||
|
const ownerEmail = typeof action.ownerEmail === "string" && action.ownerEmail.trim()
|
||||||
|
? action.ownerEmail.trim() : submittedAction.ownerEmail || action.owner || "";
|
||||||
|
const normalized = { ...action, ownerEmail, owner: ownerEmail };
|
||||||
|
const index = actions.findIndex(item => String(item.id) === String(action.id));
|
||||||
|
if (index === -1) actions.push(normalized);
|
||||||
|
else actions[index] = { ...actions[index], ...normalized };
|
||||||
|
}
|
||||||
|
const record = { status: "ready", actions, hasNext: previous?.hasNext || false,
|
||||||
|
completion: Promise.resolve(), resolveCompletion() {} };
|
||||||
|
session.records.set(id, record);
|
||||||
|
previous?.resolveCompletion?.();
|
||||||
|
session.progress.delete(previous);
|
||||||
|
queueProductsTableRender();
|
||||||
|
if (products.cachedAt) await activityCache.write("actions", id, products.cachedAt, actions, record.hasNext);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function createBadge(optyNumber) {
|
||||||
|
const summary = getSummary(optyNumber);
|
||||||
|
if (!summary) return null;
|
||||||
|
const { state, actions, records, principal, pending, uncertain } = summary;
|
||||||
const badge = environment.document.createElement("span");
|
const badge = environment.document.createElement("span");
|
||||||
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
|
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
|
||||||
badge.setAttribute("data-state", state);
|
badge.setAttribute("data-state", state);
|
||||||
badge.textContent = state === "matched" ? "\u2705 Act" : state === "missing" ? "\u26d4 Act" : state === "loading" ? "Act ..." : "Act ?";
|
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
|
||||||
badge.title = actions.length ? actions.map(action => [
|
badge.title = actions.length ? actions.map(action => [
|
||||||
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
|
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
|
||||||
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
|
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
|
||||||
@@ -76,12 +161,15 @@ 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.";
|
||||||
badge.tabIndex = 0;
|
badge.tabIndex = 0;
|
||||||
badge.setAttribute("aria-label", badge.title);
|
badge.setAttribute("aria-label", badge.title);
|
||||||
|
if (state === "missing") badge.append(createActionButton(String(optyNumber)));
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
return { reset, reopen, loadProducts, createBadge };
|
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary, applySavedAction };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
storageLocalSet
|
||||||
}) {
|
}) {
|
||||||
|
let generation = 0;
|
||||||
|
async function invalidate() {
|
||||||
|
generation += 1;
|
||||||
|
productsCacheState.inFlightProducts.clear();
|
||||||
|
productsCacheState.productsCachePromise = Promise.resolve({});
|
||||||
|
await saveProductsCache({});
|
||||||
|
}
|
||||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
|
const currentGeneration = generation;
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
|
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
@@ -24,15 +33,14 @@ function createProductsCache({
|
|||||||
})
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
if (generation === currentGeneration) {
|
||||||
fetchedAt,
|
cache[optyNumber] = { fetchedAt, items };
|
||||||
items
|
|
||||||
};
|
|
||||||
await saveProductsCache(cache).catch(() => {});
|
await saveProductsCache(cache).catch(() => {});
|
||||||
|
}
|
||||||
return { items, fetchedAt };
|
return { items, fetchedAt };
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,5 +86,5 @@ function createProductsCache({
|
|||||||
return productsCacheState.productsCacheWritePromise;
|
return productsCacheState.productsCacheWritePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ function createProductsContentJobs({
|
|||||||
preferencesState,
|
preferencesState,
|
||||||
productsContentState,
|
productsContentState,
|
||||||
renderOpportunitiesTable,
|
renderOpportunitiesTable,
|
||||||
loadWorkloadActions,
|
beginProductActivities,
|
||||||
|
completeProductActivities,
|
||||||
sendRuntimeMessage,
|
sendRuntimeMessage,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
@@ -52,6 +53,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, optyNumbers);
|
||||||
productsContentState.opptyProductsByNumber = new Map();
|
productsContentState.opptyProductsByNumber = new Map();
|
||||||
productsContentState.expandedProductOptyNumbers = new Set();
|
productsContentState.expandedProductOptyNumbers = new Set();
|
||||||
productsContentState.productsProgress = {
|
productsContentState.productsProgress = {
|
||||||
@@ -119,11 +121,6 @@ function createProductsContentJobs({
|
|||||||
cachedAt: Number(message.cachedAt) || 0,
|
cachedAt: Number(message.cachedAt) || 0,
|
||||||
error: message.error || ""
|
error: message.error || ""
|
||||||
});
|
});
|
||||||
loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
|
|
||||||
productsContentState.productsLastCachedAt = Number(message.cachedAt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
productsContentState.productsProgress = {
|
productsContentState.productsProgress = {
|
||||||
@@ -134,6 +131,7 @@ function createProductsContentJobs({
|
|||||||
cacheHits: Number(message.cacheHits) || 0
|
cacheHits: Number(message.cacheHits) || 0
|
||||||
};
|
};
|
||||||
renderProductsProgress();
|
renderProductsProgress();
|
||||||
|
if (message.state === "complete" || message.state === "complete-with-errors") completeProductActivities(message.requestId);
|
||||||
queueProductsTableRender();
|
queueProductsTableRender();
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
function createProductsDetails({
|
function createProductsDetails({
|
||||||
OPPORTUNITIES_COLUMNS,
|
OPPORTUNITIES_COLUMNS,
|
||||||
|
setBadgeContent,
|
||||||
createAiSummaryAlert,
|
createAiSummaryAlert,
|
||||||
formatOracleResponseDate,
|
formatOracleResponseDate,
|
||||||
productsContentState,
|
productsContentState,
|
||||||
@@ -17,7 +18,7 @@ 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")));
|
||||||
badge.textContent = `RM ${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;
|
||||||
@@ -31,7 +32,7 @@ function createProductsDetails({
|
|||||||
|
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "opportunities-extension-products-count";
|
button.className = "opportunities-extension-products-count";
|
||||||
button.textContent = String(count);
|
setBadgeContent(button, "products", count);
|
||||||
button.setAttribute("aria-expanded", String(isExpanded));
|
button.setAttribute("aria-expanded", String(isExpanded));
|
||||||
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
|
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
|
||||||
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
|
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
|
|||||||
try {
|
try {
|
||||||
const planNumber = String(plan.subPlanNum ?? "").trim();
|
const planNumber = String(plan.subPlanNum ?? "").trim();
|
||||||
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
|
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
|
||||||
if (!pending.has(planNumber)) pending.set(planNumber, request(planNumber).finally(() => pending.delete(planNumber)));
|
if (!pending.has(planNumber)) {
|
||||||
|
const promise = request(planNumber).finally(() => { if (pending.get(planNumber) === promise) pending.delete(planNumber); });
|
||||||
|
pending.set(planNumber, promise);
|
||||||
|
}
|
||||||
handle.showItems(await pending.get(planNumber));
|
handle.showItems(await pending.get(planNumber));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handle.showError(error.message || "Unable to load rate card.");
|
handle.showError(error.message || "Unable to load rate card.");
|
||||||
@@ -34,5 +37,5 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
|
|||||||
button.addEventListener("click", () => showRatecard(plan, button));
|
button.addEventListener("click", () => showRatecard(plan, button));
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return { createRatecardButton, showRatecard };
|
return { createRatecardButton, showRatecard, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
|
|||||||
try {
|
try {
|
||||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||||
if (!pending.has(partyNumber)) {
|
if (!pending.has(partyNumber)) {
|
||||||
pending.set(partyNumber, load(partyNumber).finally(() => pending.delete(partyNumber)));
|
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
|
||||||
|
pending.set(partyNumber, request);
|
||||||
}
|
}
|
||||||
view.render(await pending.get(partyNumber));
|
view.render(await pending.get(partyNumber));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -48,5 +49,5 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
|
|||||||
});
|
});
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return { createServiceRequestsButton };
|
return { createServiceRequestsButton, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ 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",
|
||||||
"OpportunityName_Id_c.OptyNumber", "Owner"], skipInValidFields: true, limit: 15, offset
|
"OpportunityName_Id_c.OptyNumber", "Owner"], skipInValidFields: true, limit: 15, offset
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version)) {
|
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
|
||||||
const items = [];
|
const items = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
let attempt = 1;
|
||||||
while (isActive()) {
|
while (isActive()) {
|
||||||
const payload = createPayload(offset);
|
const payload = createPayload(offset);
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -28,30 +29,33 @@ 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 } });
|
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)
|
||||||
});
|
});
|
||||||
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||||
if (!response.ok) throw new Error(`CRM service request query failed with status ${response.status}.`);
|
if (response.status !== 200) throw new Error(`CRM service request query failed with status ${response.status}.`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") {
|
if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") {
|
||||||
throw new Error("CRM service request query returned an invalid response.");
|
throw new Error("CRM service request query returned an invalid response.");
|
||||||
}
|
}
|
||||||
if (!isActive()) return null;
|
if (!isActive()) return null;
|
||||||
items.push(...data.items);
|
|
||||||
if (!data.hasMore) return items;
|
|
||||||
const received = data.count === undefined ? data.items.length : Number(data.count);
|
const received = data.count === undefined ? data.items.length : Number(data.count);
|
||||||
const current = data.offset === undefined ? offset : Number(data.offset);
|
const current = data.offset === undefined ? offset : Number(data.offset);
|
||||||
if (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|
if (data.hasMore && (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|
||||||
|| current !== offset || current + received <= offset) {
|
|| current !== offset || current + received <= offset)) {
|
||||||
throw new Error("CRM service request pagination did not advance.");
|
throw new Error("CRM service request pagination did not advance.");
|
||||||
}
|
}
|
||||||
|
items.push(...data.items);
|
||||||
|
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null);
|
||||||
|
if (!data.hasMore) return items;
|
||||||
offset = current + received;
|
offset = current + received;
|
||||||
|
attempt = 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logRequestFailure(entry, error.message || "CRM service request query failed.");
|
logRequestFailure(entry, error.message || "CRM service request query failed.");
|
||||||
throw error;
|
if (attempt === 5) throw error;
|
||||||
|
attempt += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -150,7 +150,9 @@ function createFusionOpportunities({
|
|||||||
"OptyId",
|
"OptyId",
|
||||||
"CustomerAccount.PartyUniqueName",
|
"CustomerAccount.PartyUniqueName",
|
||||||
"CustomerAccount.PartyId",
|
"CustomerAccount.PartyId",
|
||||||
"CustomerAccount.PartyNumber"
|
"CustomerAccount.PartyNumber",
|
||||||
|
"CustomerAccount.Region_c",
|
||||||
|
"CustomerAccount.PrimaryAddress.CountryCode"
|
||||||
],
|
],
|
||||||
sort: [],
|
sort: [],
|
||||||
language: "en",
|
language: "en",
|
||||||
|
|||||||
140
src/services/fusion/sr-create.js
Normal file
140
src/services/fusion/sr-create.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders }) {
|
||||||
|
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(email, debugRequests) {
|
||||||
|
const emailAddress = text(email, "current user email");
|
||||||
|
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
|
||||||
|
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
|
||||||
|
}, "requestResourceUser", debugRequests);
|
||||||
|
return numericId(resource?.resourcePartyId, "resourcePartyId");
|
||||||
|
}
|
||||||
|
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.emailAddress, 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.emailAddress, 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestPostServiceRequest, requestPostInternalServiceRequest };
|
||||||
|
}
|
||||||
228
src/services/fusion/sr-details.js
Normal file
228
src/services/fusion/sr-details.js
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFirefoxCookieStoreTabs, cookiesGetAll, cookieChanges, wait }) {
|
||||||
|
const sessions = new Map();
|
||||||
|
function cookiePart(cookie) {
|
||||||
|
if (!cookie) return null;
|
||||||
|
const match = /^ORA_OCIS_CG_SESSION_idcs-([a-zA-Z0-9-]+)_comcipapic-oalprod\.integration\.ocp\.oraclecloud\.com_([12])$/.exec(cookie.name);
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const cookieDomain = cookie.domain?.replace(/^\./, "");
|
||||||
|
if (!match || (cookieDomain && cookieDomain !== domain && !domain.endsWith(`.${cookieDomain}`))) return null;
|
||||||
|
return { key: `${match[1]}|${cookie.path || "/"}|${cookie.storeId || ""}`, part: match[2],
|
||||||
|
valid: Boolean(cookie.value) && (cookie.expirationDate == null || cookie.expirationDate > Date.now() / 1000) };
|
||||||
|
}
|
||||||
|
function watchSession(session, sender) {
|
||||||
|
const pairs = new Map();
|
||||||
|
let available;
|
||||||
|
session.cookieReady = new Promise(resolve => { available = resolve; });
|
||||||
|
const listener = change => {
|
||||||
|
const cookie = change.cookie;
|
||||||
|
const part = cookiePart(cookie);
|
||||||
|
if (!part) return;
|
||||||
|
if (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId) return;
|
||||||
|
if (!pairs.has(part.key)) pairs.set(part.key, new Map());
|
||||||
|
const pair = pairs.get(part.key);
|
||||||
|
if (change.removed || !part.valid) pair.delete(part.part);
|
||||||
|
else pair.set(part.part, cookie);
|
||||||
|
if (pair.size === 2 && [...pair.values()].every(item => cookiePart(item)?.valid)) available(new Set([part.key]));
|
||||||
|
session.wake?.();
|
||||||
|
};
|
||||||
|
cookieChanges?.addListener(listener);
|
||||||
|
session.unwatch = () => cookieChanges?.removeListener(listener);
|
||||||
|
}
|
||||||
|
function waitForSessionCookies(session, sender) {
|
||||||
|
if (session.cookieWait) return session.cookieWait;
|
||||||
|
session.cookieWait = (async () => {
|
||||||
|
const deadline = Date.now() + 1000;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
// Install the wake-up before reading so a renewal during getAll is not missed.
|
||||||
|
const changed = new Promise(resolve => { session.wake = resolve; });
|
||||||
|
const current = await sessionCookies(sender);
|
||||||
|
const renewed = [...current].some(pair => !session.baseline.has(pair));
|
||||||
|
if (renewed || Date.now() >= deadline) {
|
||||||
|
session.baseline = current;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
await Promise.race([changed, wait(250)]);
|
||||||
|
}
|
||||||
|
return sessionCookies(sender);
|
||||||
|
})().finally(() => { session.cookieWait = null; session.wake = null; });
|
||||||
|
return session.cookieWait;
|
||||||
|
}
|
||||||
|
async function sessionCookies(sender) {
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const details = { domain };
|
||||||
|
if (sender?.tab?.cookieStoreId) details.storeId = sender.tab.cookieStoreId;
|
||||||
|
try {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const cookie of await cookiesGetAll(details)) {
|
||||||
|
const part = cookiePart(cookie);
|
||||||
|
if (!part?.valid) continue;
|
||||||
|
if (!groups.has(part.key)) groups.set(part.key, {});
|
||||||
|
groups.get(part.key)[part.part] = [cookie.name, cookie.value, cookie.expirationDate ?? null];
|
||||||
|
}
|
||||||
|
// Fingerprints remain in memory only; the browser sends the actual Cookie header.
|
||||||
|
return new Set([...groups.values()].filter(pair => pair[1] && pair[2]).map(pair => JSON.stringify([pair[1], pair[2]])));
|
||||||
|
} catch { return new Set(); }
|
||||||
|
}
|
||||||
|
async function requestLookupWithSession(operation, message, sender, initialize = false) {
|
||||||
|
const existingCookies = initialize ? await sessionCookies(sender) : null;
|
||||||
|
if (existingCookies?.size) return { ok: true, debugRequests: [] };
|
||||||
|
const logs = [];
|
||||||
|
const query = async () => {
|
||||||
|
try {
|
||||||
|
const result = await operation(message);
|
||||||
|
logs.push(...(result.debugRequests || []));
|
||||||
|
return { ...result, debugRequests: logs };
|
||||||
|
} catch (error) {
|
||||||
|
logs.push(...(error.debugRequests || []));
|
||||||
|
error.debugRequests = logs;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!initialize) {
|
||||||
|
try { return await query(); } catch (error) { if (error.httpStatus !== 401) throw error; }
|
||||||
|
}
|
||||||
|
const key = sender?.tab?.cookieStoreId || "default";
|
||||||
|
let session = sessions.get(key);
|
||||||
|
if (!session) {
|
||||||
|
const details = {
|
||||||
|
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
|
||||||
|
active: false
|
||||||
|
};
|
||||||
|
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
|
||||||
|
if (supportsFirefoxCookieStoreTabs() && sender?.tab?.cookieStoreId) details.cookieStoreId = sender.tab.cookieStoreId;
|
||||||
|
session = { users: 0, closed: null, baseline: null };
|
||||||
|
watchSession(session, sender);
|
||||||
|
session.tab = (async () => {
|
||||||
|
session.baseline = existingCookies || await sessionCookies(sender);
|
||||||
|
return tabsCreate(details);
|
||||||
|
})();
|
||||||
|
sessions.set(key, session);
|
||||||
|
}
|
||||||
|
session.users += 1;
|
||||||
|
let tab;
|
||||||
|
try {
|
||||||
|
tab = await session.tab;
|
||||||
|
const deadline = Date.now() + 60000;
|
||||||
|
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
|
||||||
|
const pendingCookies = initialize && attempt === 0
|
||||||
|
? sessionCookies(sender) : waitForSessionCookies(session, sender);
|
||||||
|
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
|
||||||
|
if (initialize) {
|
||||||
|
// Proactive preparation observes cookies only; API requests belong to the form.
|
||||||
|
if (!cookies.size) continue;
|
||||||
|
await closeSessionTab();
|
||||||
|
return { ok: true, debugRequests: [] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await query();
|
||||||
|
// Any successful lookup confirms the shared session; stop the page immediately.
|
||||||
|
await closeSessionTab();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.httpStatus !== 401) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = logs;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
session.users -= 1;
|
||||||
|
if (session.users === 0) {
|
||||||
|
sessions.delete(key);
|
||||||
|
session.unwatch();
|
||||||
|
await closeSessionTab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeSessionTab() {
|
||||||
|
if (!session.closed && Number.isInteger(tab?.id)) {
|
||||||
|
session.closed = tabsRemove(tab.id).catch(() => {});
|
||||||
|
}
|
||||||
|
return session.closed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function requestInternalServiceRequest(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const id = String(message.srId ?? "");
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric SrId is required.");
|
||||||
|
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
|
||||||
|
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
|
||||||
|
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
|
||||||
|
const query = async () => {
|
||||||
|
const result = await fetchJson(url, { method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", Authorization: "Session" }
|
||||||
|
}, "requestInternalServiceRequest", debugRequests);
|
||||||
|
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const result = await query();
|
||||||
|
const items = result.items.flatMap(item => {
|
||||||
|
const children = item.ServiceRequest_SRToInternalSR_Tgt;
|
||||||
|
if (children == null) return [];
|
||||||
|
if (!Array.isArray(children) || children.some(child => !child || typeof child !== "object")) throw new Error("Invalid linked SR collection.");
|
||||||
|
return children;
|
||||||
|
});
|
||||||
|
return { ok: true, items, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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") };
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
function createHttpBackgroundJson({
|
function createHttpBackgroundJson({
|
||||||
previewDebugBody,
|
previewDebugBody,
|
||||||
DEBUG_BODY_LIMIT,
|
observeResponseHeaders = () => {},
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
async function fetchJson(url, options, requestName, debugRequests = null) {
|
async function fetchJson(url, options, requestName, debugRequests = null, { allowEmpty = false } = {}) {
|
||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
||||||
let response;
|
let response;
|
||||||
@@ -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, "");
|
||||||
@@ -25,7 +26,10 @@ function createHttpBackgroundJson({
|
|||||||
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${requestName} failed with status ${response.status}.`);
|
const error = new Error(`${requestName} failed with status ${response.status}.`);
|
||||||
|
error.httpStatus = response.status;
|
||||||
|
debugEntry.error = error.message;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responsePayload.error) {
|
if (responsePayload.error) {
|
||||||
@@ -33,6 +37,7 @@ function createHttpBackgroundJson({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (allowEmpty && !responsePayload.text.trim()) return null;
|
||||||
return parseJsonResponse(responsePayload.text, requestName);
|
return parseJsonResponse(responsePayload.text, requestName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
error.httpStatus = response.status;
|
error.httpStatus = response.status;
|
||||||
@@ -121,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) {
|
||||||
|
|||||||
83
src/services/spa/action-options.js
Normal file
83
src/services/spa/action-options.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
function createSpaActionOptions({ fetchJson }) {
|
||||||
|
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||||
|
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
|
||||||
|
async function request(message, name, path, normalize) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await fetchJson(`${baseUrl}/${path()}`, {
|
||||||
|
method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
|
||||||
|
}, name, debugRequests);
|
||||||
|
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
|
||||||
|
return { ok: true, ...normalize(result), debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
const entry = debugRequests.at(-1);
|
||||||
|
if (entry) { entry.status = "failed"; entry.error = error.message; }
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requestActionTeamMembers(message) {
|
||||||
|
return request(message, "requestActionTeamMembers", () => {
|
||||||
|
const id = String(message.workloadId ?? "").trim();
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
|
||||||
|
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
|
||||||
|
}, result => {
|
||||||
|
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
|
||||||
|
throw new Error("requestActionTeamMembers returned an invalid collection.");
|
||||||
|
}
|
||||||
|
return { members: result.map(item => ({ email: item.email })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function requestActionFindLookup(message) {
|
||||||
|
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
|
||||||
|
const lookups = {};
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|
||||||
|
|| typeof item?.name !== "string" || !item.name.trim())) {
|
||||||
|
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
|
||||||
|
}
|
||||||
|
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
|
||||||
|
}
|
||||||
|
return { lookups };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function requestMgmtAction(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
const payload = message.payload;
|
||||||
|
const action = payload?.action;
|
||||||
|
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
|
||||||
|
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
|
||||||
|
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
|
||||||
|
}
|
||||||
|
for (const key of ["startDate", "endDate"]) {
|
||||||
|
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
|
||||||
|
}
|
||||||
|
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
|
||||||
|
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
|
||||||
|
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
|
||||||
|
}
|
||||||
@@ -15,12 +15,6 @@ function createSpaCurrentUser({ fetchJson }) {
|
|||||||
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
|
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
|
||||||
error.debugRequests = debugRequests;
|
error.debugRequests = debugRequests;
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
// The identity token is not needed by content scripts or debug, including on failures.
|
|
||||||
for (const entry of debugRequests) {
|
|
||||||
entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2);
|
|
||||||
entry.responseBodyTruncated = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { requestCurrentUser };
|
return { requestCurrentUser };
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,21 @@ function createMessageTypes() {
|
|||||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||||
|
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
||||||
|
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
||||||
|
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
||||||
|
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
|
||||||
|
requestSrLookupPrimaryPillar: "opportunitiesExtension.requestSrLookupPrimaryPillar",
|
||||||
|
requestPostServiceRequest: "opportunitiesExtension.requestPostServiceRequest",
|
||||||
|
requestPostInternalServiceRequest: "opportunitiesExtension.requestPostInternalServiceRequest",
|
||||||
|
prepareSrSession: "opportunitiesExtension.prepareSrSession",
|
||||||
|
requestSrLookupLanguage: "opportunitiesExtension.requestSrLookupLanguage",
|
||||||
|
requestSrLookupTechPlatform: "opportunitiesExtension.requestSrLookupTechPlatform",
|
||||||
|
requestLookupTypePlatform: "opportunitiesExtension.requestLookupTypePlatform",
|
||||||
|
requestInternalSrServices: "opportunitiesExtension.requestInternalSrServices",
|
||||||
|
requestSrLookupOpportunityBusiness: "opportunitiesExtension.requestSrLookupOpportunityBusiness",
|
||||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||||
|
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||||
|
|||||||
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
110
tests/actionOptions.test.cjs
Normal file
110
tests/actionOptions.test.cjs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
|
const lookups = () => Object.fromEntries(["ROLE", "NAME", "TEAM", "STATUS", "COMPLEXITY"].map(key => [
|
||||||
|
`WWB_ACTION_${key}`, [{ code: `${key}_CODE`, name: `${key} label` }]
|
||||||
|
]));
|
||||||
|
const teamType = "opportunitiesExtension.requestActionTeamMembers";
|
||||||
|
const lookupType = "opportunitiesExtension.requestActionFindLookup";
|
||||||
|
|
||||||
|
test("Save sends the WWB POST, accepts empty success and never retries errors", async () => {
|
||||||
|
const payload = { parentType: "WORKLOAD", parentId: "123", action: {
|
||||||
|
ownerEmail: "owner@example.invalid", role: "CA", name: "REVIEW", team: "TEAM", tags: "",
|
||||||
|
startDate: "2026-09-14T00:00:00.000Z", endDate: "2026-09-15T00:00:00.000Z",
|
||||||
|
status: "COMPLETE", complexity: "LOW", notes: [], objective: "",
|
||||||
|
createdBy: "creator@example.invalid", updatedBy: "creator@example.invalid"
|
||||||
|
} };
|
||||||
|
for (const status of [200, 201, 204, 403, 500]) {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family: "chrome", fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return status === 204 ? new Response(null, { status }) : response({ id: 123 }, status);
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type: "opportunitiesExtension.requestMgmtAction", payload, authorization: { value: "secret-wwb" } });
|
||||||
|
assert.equal(result.ok, status < 300, `${status}: ${result.error}`);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.ok(calls[0].url.endsWith("/wwb/mgmt/actions"));
|
||||||
|
assert.equal(calls[0].options.method, "POST");
|
||||||
|
assert.equal(calls[0].options.headers["Wwb-Provider-Authorization"], "secret-wwb");
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].options.body), payload);
|
||||||
|
assert.ok(!JSON.stringify(result).includes("secret-wwb"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: Action options use dynamic RevnId, WWB-TS and masked GET diagnostics`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (url.endsWith("consumerTokenService")) return response([
|
||||||
|
{ tokenService: "ACCP-TS", uiaasHeader: { value: "summary-token" } },
|
||||||
|
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "wrong-token" } },
|
||||||
|
{ tokenService: "WWB-TS", uiaasHeader: { value: "synthetic-wwb" } }
|
||||||
|
]);
|
||||||
|
return response(url.includes("teamMembers") ? [{ email: "owner@example.invalid" }] : lookups());
|
||||||
|
} });
|
||||||
|
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
||||||
|
const authorization = auth.authorizations.workloadActions;
|
||||||
|
const team = await h.dispatch({ type: teamType, workloadId: "987654", authorization, url: "https://example.invalid" });
|
||||||
|
const lookup = await h.dispatch({ type: lookupType, authorization });
|
||||||
|
assert.equal(team.ok, true);
|
||||||
|
assert.deepEqual(team.members, [{ email: "owner@example.invalid" }]);
|
||||||
|
assert.deepEqual(lookup.lookups, lookups());
|
||||||
|
const base = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||||
|
assert.equal(calls[1].url, `${base}/987654/teamMembers?includeLeads=true`);
|
||||||
|
assert.equal(calls[2].url, `${base}/fndLookup`);
|
||||||
|
for (const call of calls.slice(1)) {
|
||||||
|
assert.equal(call.options.method, "GET");
|
||||||
|
assert.equal(call.options.credentials, "include");
|
||||||
|
assert.deepEqual({ ...call.options.headers }, { Accept: "application/json", "Wwb-Provider-Authorization": "synthetic-wwb" });
|
||||||
|
}
|
||||||
|
for (const [result, name] of [[team, "requestActionTeamMembers"], [lookup, "requestActionFindLookup"]]) {
|
||||||
|
const log = result.debugRequests[0];
|
||||||
|
assert.equal(log.name, name);
|
||||||
|
assert.equal(log.status, "success");
|
||||||
|
assert.equal(log.httpStatus, 200);
|
||||||
|
assert.ok(log.durationMs >= 0);
|
||||||
|
assert.equal(log.requestHeaders["Wwb-Provider-Authorization"], "(present)");
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /synthetic-wwb|wrong-token/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("invalid workload IDs and missing authorization never fetch or accept an arbitrary target", async () => {
|
||||||
|
const h = loadBackground({ fetch: () => assert.fail("No network expected") });
|
||||||
|
for (const workloadId of [undefined, "", "abc", "12/../fndLookup", "12?other=1"]) {
|
||||||
|
assert.equal((await h.dispatch({ type: teamType, workloadId, authorization: { value: "synthetic" } })).ok, false);
|
||||||
|
}
|
||||||
|
for (const type of [teamType, lookupType]) {
|
||||||
|
assert.equal((await h.dispatch({ type, workloadId: "123" })).ok, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Action options require exactly HTTP 200 and valid collections and preserve failed debug entries", async () => {
|
||||||
|
for (const type of [teamType, lookupType]) {
|
||||||
|
const valid = type === teamType ? [{ email: "owner@example.invalid" }] : lookups();
|
||||||
|
for (const fetch of [
|
||||||
|
async () => response(valid, 201), async () => response({}, 401), async () => response({}, 403),
|
||||||
|
async () => { throw new Error("Synthetic network failure"); },
|
||||||
|
async () => new Response("<html>Unexpected session page</html>"),
|
||||||
|
async () => response(type === teamType ? [{ name: "Missing email" }] : { ...lookups(), WWB_ACTION_NAME: null })
|
||||||
|
]) {
|
||||||
|
const h = loadBackground({ fetch });
|
||||||
|
const result = await h.dispatch({ type, workloadId: "123", authorization: { value: "synthetic-secret" } });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.debugRequests.length, 1);
|
||||||
|
assert.equal(result.debugRequests[0].status, "failed");
|
||||||
|
assert.ok(result.error);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /synthetic-secret/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty option lists are valid responses, not invented defaults", async () => {
|
||||||
|
const empty = Object.fromEntries(Object.keys(lookups()).map(key => [key, []]));
|
||||||
|
const h = loadBackground({ fetch: async url => response(url.includes("teamMembers") ? [] : empty) });
|
||||||
|
const members = await h.dispatch({ type: teamType, workloadId: "123", authorization: { value: "synthetic" } });
|
||||||
|
const lookup = await h.dispatch({ type: lookupType, authorization: { value: "synthetic" } });
|
||||||
|
assert.deepEqual(members.members, []);
|
||||||
|
assert.deepEqual(lookup.lookups, empty);
|
||||||
|
});
|
||||||
179
tests/activityCache.test.cjs
Normal file
179
tests/activityCache.test.cjs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
function setup(family, saved = {}, principal = "user@example.invalid") {
|
||||||
|
const local = {
|
||||||
|
get: async key => key === null ? { ...saved } : ({ [key]: saved[key] }),
|
||||||
|
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values))),
|
||||||
|
remove: async keys => keys.forEach(key => delete saved[key])
|
||||||
|
};
|
||||||
|
const api = family === "firefox" ? { browser: { storage: { local } } } : {
|
||||||
|
chrome: { runtime: {}, storage: { local: {
|
||||||
|
get: (key, done) => local.get(key).then(done), set: (values, done) => local.set(values).then(done),
|
||||||
|
remove: (keys, done) => local.remove(keys).then(done)
|
||||||
|
} } }
|
||||||
|
};
|
||||||
|
const document = { createElement: () => ({ append() {}, setAttribute() {}, addEventListener() {} }) };
|
||||||
|
document.createElementNS = (_, tag) => document.createElement(tag);
|
||||||
|
const app = loadApplication("content", { ...api, document });
|
||||||
|
app.authSessionState.principal = principal;
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.preferencesState.opptyProductsCacheHours = 24;
|
||||||
|
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
|
app.activityProgress.update = () => {};
|
||||||
|
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-token" } });
|
||||||
|
const calls = [];
|
||||||
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => {
|
||||||
|
calls.push("sr");
|
||||||
|
return [{ SrNumber: "SR1", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "OP1" } }];
|
||||||
|
};
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async () => {
|
||||||
|
calls.push("actions");
|
||||||
|
return { ok: true, actions: [{ owner: principal, name: "Test action" }], hasNext: false };
|
||||||
|
};
|
||||||
|
async function load(cachedAt, cacheOnly) {
|
||||||
|
app.opportunityCrmServiceRequests.reset();
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic-token", 1, { cachedAt, cacheOnly });
|
||||||
|
const products = { items: [{ RevnId: "123" }], cachedAt, fromCache: cacheOnly };
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("OP1", products);
|
||||||
|
app.opportunityWorkloadActions.loadProducts(products, true, { cachedAt, cacheOnly });
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
return { app, calls, load, saved, local };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("all cached opportunities load missing Actions despite filters, then reuse their completed cache", async () => {
|
||||||
|
const h = setup("firefox");
|
||||||
|
const stamp = Date.now() - 1000;
|
||||||
|
const app = h.app;
|
||||||
|
app.opportunitiesFilterState.tableSearch = "hide every opportunity";
|
||||||
|
app.productsContentJobs.renderProductsProgress = () => {};
|
||||||
|
const products = Array.from({ length: 7 }, (_, index) => [String(index + 1), {
|
||||||
|
items: [{ RevnId: String(index + 1) }], cachedAt: stamp, fromCache: true
|
||||||
|
}]);
|
||||||
|
app.productsContentState.opptyProductsByNumber = new Map(products);
|
||||||
|
await app.activityCache.write("actions", "1", stamp, []);
|
||||||
|
await app.activityCache.write("actions", "2", stamp - 1, []);
|
||||||
|
const queried = [];
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async message => {
|
||||||
|
queried.push(message.revnId);
|
||||||
|
return { ok: true, actions: message.revnId === "3" ? [{ owner: app.authSessionState.principal }] : [], hasNext: false };
|
||||||
|
};
|
||||||
|
async function cycle(id) {
|
||||||
|
app.productsContentState.currentProductsRequestId = id;
|
||||||
|
app.productActivities.begin(id, "synthetic-token");
|
||||||
|
await app.productActivities.complete(id);
|
||||||
|
}
|
||||||
|
await cycle("first");
|
||||||
|
assert.deepEqual(queried.sort(), ["2", "3", "4", "5", "6", "7"]);
|
||||||
|
assert.deepEqual(h.calls, ["sr"], "a missing SR cache must not block the Actions queue");
|
||||||
|
for (const [number] of products) {
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary(number).state, number === "3" ? "matched" : "missing");
|
||||||
|
}
|
||||||
|
app.opportunityWorkloadActions.reset();
|
||||||
|
app.opportunityCrmServiceRequests.reset();
|
||||||
|
await cycle("second");
|
||||||
|
assert.equal(queried.length, 6, "valid matching action cache avoids all repeat requests");
|
||||||
|
assert.deepEqual(h.calls, ["sr"]);
|
||||||
|
});
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: Current Quarter -> Next Quarter -> Current Quarter restores SR hours and filters without requests`, async () => {
|
||||||
|
const h = setup(family);
|
||||||
|
const currentStamp = Date.now() - 2000;
|
||||||
|
const nextStamp = currentStamp + 1000;
|
||||||
|
let hours = 2;
|
||||||
|
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => {
|
||||||
|
h.calls.push("sr");
|
||||||
|
return [{ SrNumber: "SR1", ReportedHours_c: hours, OpportunityName_Id_c: { OptyNumber: "OP1" } }];
|
||||||
|
};
|
||||||
|
await h.load(currentStamp, false);
|
||||||
|
hours = 8;
|
||||||
|
await h.load(nextStamp, false);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 8);
|
||||||
|
const count = h.calls.length;
|
||||||
|
await h.load(currentStamp, true);
|
||||||
|
assert.equal(h.calls.length, count);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "2h");
|
||||||
|
h.app.opportunitiesTable.renderOpportunitiesTable = () => {};
|
||||||
|
h.app.activityFilters.toggle("sr", "under4");
|
||||||
|
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), true);
|
||||||
|
await h.load(nextStamp, true);
|
||||||
|
assert.equal(h.calls.length, count);
|
||||||
|
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), false);
|
||||||
|
const reopened = setup(family, h.saved);
|
||||||
|
await reopened.load(currentStamp, true);
|
||||||
|
assert.deepEqual(reopened.calls, []);
|
||||||
|
assert.equal(reopened.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 2);
|
||||||
|
});
|
||||||
|
test(`${family}: activities persist across instances with product timestamps and no credential storage`, async () => {
|
||||||
|
const h = setup(family);
|
||||||
|
const stamp = Date.now() - 1000;
|
||||||
|
await h.load(stamp, false);
|
||||||
|
assert.deepEqual(h.calls, ["sr", "actions"]);
|
||||||
|
assert.equal(Object.keys(h.saved).length, 2);
|
||||||
|
Object.values(h.saved).forEach(entry => assert.equal(entry.fetchedAt, stamp));
|
||||||
|
assert.doesNotMatch(JSON.stringify(h.saved), /synthetic-token|debugRequests|authorization/i);
|
||||||
|
const restored = setup(family, h.saved);
|
||||||
|
await restored.load(stamp, true);
|
||||||
|
assert.deepEqual(restored.calls, []);
|
||||||
|
assert.equal(restored.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "4h");
|
||||||
|
assert.equal(restored.app.opportunityWorkloadActions.createBadge("OP1").textContent, "1");
|
||||||
|
await restored.load(stamp, false);
|
||||||
|
assert.deepEqual(restored.calls, [], "valid matching cache also deduplicates repeated fresh-product notifications");
|
||||||
|
const other = setup(family, h.saved, "other@example.invalid");
|
||||||
|
await other.load(stamp, true);
|
||||||
|
assert.deepEqual(other.calls, []);
|
||||||
|
assert.equal(other.app.opportunityCrmServiceRequests.createBadge("OP1"), null);
|
||||||
|
assert.equal(other.app.opportunityWorkloadActions.createBadge("OP1").textContent, "?");
|
||||||
|
await restored.load(stamp + 1, false);
|
||||||
|
assert.deepEqual(restored.calls, ["sr", "actions"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("TTL follows product preferences, rejects stale/mismatched cache and stores empty success", async () => {
|
||||||
|
const h = setup("firefox");
|
||||||
|
const stamp = Date.now() - 2 * 3600000;
|
||||||
|
await h.app.activityCache.write("sr", "all", stamp, []);
|
||||||
|
assert.ok(await h.app.activityCache.read("sr", "all", stamp));
|
||||||
|
h.app.preferencesState.opptyProductsCacheHours = 1;
|
||||||
|
assert.equal(await h.app.activityCache.read("sr", "all", stamp), null);
|
||||||
|
h.app.preferencesState.opptyProductsCacheHours = 24;
|
||||||
|
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
|
||||||
|
assert.equal(await h.app.activityCache.read("sr", "all", Date.now() + 100000), null);
|
||||||
|
await h.load(stamp + 1, true);
|
||||||
|
assert.deepEqual(h.calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("versioned SR cache reads matching legacy entries and prunes expired snapshots only", async () => {
|
||||||
|
const h = setup("firefox");
|
||||||
|
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all"])}`;
|
||||||
|
const stamp = Date.now() - 1000;
|
||||||
|
h.saved[legacyKey] = { fetchedAt: stamp, items: [], hasNext: false };
|
||||||
|
assert.ok(await h.app.activityCache.read("sr", "all", stamp));
|
||||||
|
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
|
||||||
|
const expiredStamp = Date.now() - 25 * 3600000;
|
||||||
|
const expiredKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all", expiredStamp])}`;
|
||||||
|
h.saved[expiredKey] = { fetchedAt: expiredStamp, items: [], hasNext: false };
|
||||||
|
h.saved.preferences = { keep: true };
|
||||||
|
await h.app.activityCache.write("sr", "all", stamp + 1, []);
|
||||||
|
assert.equal(h.saved[expiredKey], undefined);
|
||||||
|
assert.ok(h.saved[legacyKey]);
|
||||||
|
assert.equal(h.saved.preferences.keep, true);
|
||||||
|
await h.app.activityCache.invalidate();
|
||||||
|
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
|
||||||
|
assert.equal(h.saved.preferences.keep, true);
|
||||||
|
});
|
||||||
|
test("storage failure does not lose live activity results and failures are never cached", async () => {
|
||||||
|
const h = setup("firefox");
|
||||||
|
h.local.get = async () => { throw new Error("Synthetic storage read failure"); };
|
||||||
|
h.local.set = async () => { throw new Error("Synthetic storage write failure"); };
|
||||||
|
await h.load(Date.now() - 1000, false);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "4h");
|
||||||
|
const failed = setup("firefox");
|
||||||
|
failed.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => { throw new Error("Synthetic failure"); };
|
||||||
|
failed.app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: false });
|
||||||
|
await failed.load(Date.now() - 1000, false);
|
||||||
|
assert.deepEqual(failed.saved, {});
|
||||||
|
});
|
||||||
254
tests/activityFilters.test.cjs
Normal file
254
tests/activityFilters.test.cjs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; }
|
||||||
|
append(...nodes) { this.children.push(...nodes); }
|
||||||
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
|
addEventListener(key, handler) { this.listeners[key] = handler; }
|
||||||
|
}
|
||||||
|
function setup() {
|
||||||
|
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
let renders = 0;
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable = () => { renders += 1; };
|
||||||
|
const sr = new Map(), actions = new Map();
|
||||||
|
app.opportunityCrmServiceRequests.getSummary = number => sr.get(number);
|
||||||
|
app.opportunityWorkloadActions.getSummary = number => actions.get(number);
|
||||||
|
const form = new Element("form"), status = new Element("status"), stage = new Element("stage");
|
||||||
|
const controls = app.activityFilters.createControls(form, status, stage);
|
||||||
|
const buttons = kind => controls.children[kind === "sr" ? 0 : 1].children[1].children;
|
||||||
|
const button = (kind, value) => buttons(kind).find(node => node.attributes["data-value"] === value);
|
||||||
|
return { app, sr, actions, form, controls, status, stage, button, renders: () => renders,
|
||||||
|
matches: number => app.activityFilters.matches({ OptyNumber: number }),
|
||||||
|
click: (kind, value) => button(kind, value).listeners.click() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("activity badge filters precede Status, are accessible and combine without network", () => {
|
||||||
|
const h = setup();
|
||||||
|
assert.equal(h.controls.children[2], h.status);
|
||||||
|
assert.equal(h.controls.children[0].children[0].textContent, "Service Request");
|
||||||
|
assert.equal(h.controls.children[0].children[1].attributes["aria-label"], "Filter by Service Request");
|
||||||
|
assert.equal(h.controls.children[3], h.stage);
|
||||||
|
assert.equal(h.form.attributes["data-activity-filters"], "true");
|
||||||
|
assert.equal(h.button("sr", "matched").textContent, "");
|
||||||
|
assert.equal(h.button("actions", "missing").textContent, "");
|
||||||
|
assert.equal(h.button("actions", "matched").type, "button");
|
||||||
|
assert.equal(h.controls.children[1].children[0].textContent, "WL Action");
|
||||||
|
for (const value of ["matched", "missing"]) {
|
||||||
|
assert.equal(h.button("actions", value).textContent, "");
|
||||||
|
assert.ok(h.button("actions", value).title.startsWith("Whether the Action is registered in Workload Workbench.\n"));
|
||||||
|
assert.ok(h.button("sr", value).title.startsWith("Whether a Service Request has been created for the opportunities.\n"));
|
||||||
|
for (const kind of ["sr", "actions"]) {
|
||||||
|
const button = h.button(kind, value);
|
||||||
|
assert.ok(button.title.endsWith(button.attributes["aria-label"]));
|
||||||
|
assert.equal(button.children.length, 1);
|
||||||
|
assert.equal(button.children[0].tag, "svg");
|
||||||
|
assert.equal(button.children[0].attributes["data-badge-icon"], kind);
|
||||||
|
assert.equal(button.children[0].attributes.stroke, "currentColor");
|
||||||
|
assert.equal(button.children[0].attributes["aria-hidden"], "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(h.button("sr", "under4").textContent, "< 4h");
|
||||||
|
assert.equal(h.button("sr", "under4").children[0].attributes["data-badge-icon"], "sr");
|
||||||
|
assert.notEqual(h.button("actions", "matched").attributes["aria-label"], h.button("actions", "missing").attributes["aria-label"]);
|
||||||
|
assert.notEqual(h.button("sr", "matched").attributes["aria-label"], h.button("sr", "missing").attributes["aria-label"]);
|
||||||
|
h.sr.set("A", { state: "matched" });
|
||||||
|
h.actions.set("A", { state: "missing" });
|
||||||
|
h.sr.set("B", { state: "missing" });
|
||||||
|
h.actions.set("B", { state: "matched" });
|
||||||
|
assert.equal(h.matches("unknown"), true);
|
||||||
|
h.click("sr", "missing");
|
||||||
|
assert.equal(h.matches("A"), true);
|
||||||
|
assert.equal(h.matches("B"), false);
|
||||||
|
assert.equal(h.matches("unknown"), false);
|
||||||
|
h.click("actions", "missing");
|
||||||
|
assert.equal(h.matches("A"), false, "SR and Action use AND");
|
||||||
|
h.actions.set("A", { state: "matched" });
|
||||||
|
assert.equal(h.matches("A"), true);
|
||||||
|
assert.equal(h.renders(), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SR below four hours is exclusive and regular SR options can replace it", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.sr.set("A", { state: "matched", hasUnderFourHours: true });
|
||||||
|
h.sr.set("B", { state: "matched", hasUnderFourHours: false });
|
||||||
|
h.click("sr", "under4");
|
||||||
|
assert.equal(h.button("sr", "matched").attributes["aria-pressed"], "false");
|
||||||
|
assert.equal(h.button("sr", "missing").attributes["aria-pressed"], "false");
|
||||||
|
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "true");
|
||||||
|
assert.equal(h.matches("A"), true);
|
||||||
|
assert.equal(h.matches("B"), false);
|
||||||
|
h.click("sr", "matched");
|
||||||
|
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
|
||||||
|
assert.equal(h.matches("B"), true);
|
||||||
|
h.click("sr", "matched");
|
||||||
|
assert.equal(h.matches("A"), false, "empty selection matches no records, like Status");
|
||||||
|
h.click("sr", "under4");
|
||||||
|
h.click("sr", "missing");
|
||||||
|
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
|
||||||
|
assert.equal(h.matches("A"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filter vectors retain their fixed slot without duplication when selection changes", () => {
|
||||||
|
const h = setup();
|
||||||
|
const options = [["sr", "matched"], ["sr", "under4"], ["sr", "missing"], ["actions", "matched"], ["actions", "missing"]];
|
||||||
|
const icons = options.map(([kind, value]) => h.button(kind, value).children[0]);
|
||||||
|
for (const [kind, value] of options) h.click(kind, value);
|
||||||
|
h.app.activityFilters.render();
|
||||||
|
options.forEach(([kind, value], index) => {
|
||||||
|
const button = h.button(kind, value);
|
||||||
|
assert.deepEqual(button.children, [icons[index]]);
|
||||||
|
assert.equal(icons[index].attributes.viewBox, "0 0 24 24");
|
||||||
|
assert.equal(icons[index].attributes.width, "16");
|
||||||
|
assert.equal(icons[index].attributes.height, "16");
|
||||||
|
assert.equal(icons[index].attributes["stroke-width"], "2.25");
|
||||||
|
assert.equal(icons[index].attributes.focusable, "false");
|
||||||
|
});
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
|
const shared = css.match(/\.opportunities-extension-cell-actions \.opportunities-extension-badge-icon,\s*\.opportunities-extension-activity-filter-button \.opportunities-extension-badge-icon \{([^}]+)\}/);
|
||||||
|
assert.ok(shared, "filters and opportunity badges share the same vector positioning");
|
||||||
|
for (const rule of ["order: -1;", "flex: 0 0 16px;", "width: 16px;", "height: 16px;", "stroke: currentColor;"]) {
|
||||||
|
assert.ok(shared[1].includes(rule), rule);
|
||||||
|
}
|
||||||
|
assert.match(css, /activity-filter-button \{\s*min-width: 32px;\s*gap: 4px;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.click("sr", "matched");
|
||||||
|
h.click("actions", "matched");
|
||||||
|
for (const state of ["idle", "loading", "error", "matched"]) {
|
||||||
|
h.sr.set("A", { state });
|
||||||
|
h.actions.set("A", { state: "missing" });
|
||||||
|
assert.equal(h.matches("A"), false);
|
||||||
|
h.sr.set("A", { state: "missing" });
|
||||||
|
h.actions.set("A", { state });
|
||||||
|
assert.equal(h.matches("A"), false);
|
||||||
|
}
|
||||||
|
h.actions.set("A", { state: "missing" });
|
||||||
|
assert.equal(h.matches("A"), true);
|
||||||
|
h.app.currentUser.allowsCrmActivities = () => false;
|
||||||
|
h.app.activityFilters.render();
|
||||||
|
assert.equal(h.matches("unknown"), true);
|
||||||
|
assert.equal(h.controls.children[0].hidden, true);
|
||||||
|
assert.equal(h.controls.children[1].hidden, true);
|
||||||
|
assert.equal(h.form.attributes["data-activity-filters"], "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SALES_REP"], false ], [ ["SOLUTION_ENG_NOTERR"], true ]]) {
|
||||||
|
test(`requestCurrentUser roles ${JSON.stringify(roles)} control actual filter visibility`, async () => {
|
||||||
|
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
|
||||||
|
const calls = [];
|
||||||
|
let finish;
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = message => {
|
||||||
|
calls.push(message.type);
|
||||||
|
return new Promise(resolve => { finish = resolve; });
|
||||||
|
};
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable = () => {};
|
||||||
|
const form = new Element("form");
|
||||||
|
const controls = app.activityFilters.createControls(form, new Element("status"), new Element("stage"));
|
||||||
|
assert.equal(controls.children[0].hidden, true, "hidden before identity resolves");
|
||||||
|
assert.equal(controls.children[1].hidden, true);
|
||||||
|
const identity = app.currentUser.ensure();
|
||||||
|
finish({ ok: true, user: { roles } });
|
||||||
|
await identity;
|
||||||
|
app.activityFilters.render();
|
||||||
|
assert.equal(controls.children[0].hidden, !allowed);
|
||||||
|
assert.equal(controls.children[1].hidden, !allowed);
|
||||||
|
if (!allowed) {
|
||||||
|
app.opportunityCrmServiceRequests.getSummary = () => assert.fail("must not inspect SR data");
|
||||||
|
app.opportunityWorkloadActions.getSummary = () => assert.fail("must not inspect Actions data");
|
||||||
|
app.activityFilters.toggle("sr", "under4");
|
||||||
|
assert.equal(app.activityFilters.matches({ OptyNumber: "A" }), true);
|
||||||
|
}
|
||||||
|
assert.deepEqual(calls, ["opportunitiesExtension.requestCurrentUser"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("SR filter summary uses actual records: zero, decimals, exact four, missing and multiple hours", async () => {
|
||||||
|
const app = loadApplication("content");
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
|
app.activityProgress.update = () => {};
|
||||||
|
const examples = [[0, true], [3.99, true], [4, false], [8, false], [null, false], ["", false], [" ", false], ["3.5", true], ["bad", false]];
|
||||||
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
|
||||||
|
...examples.map(([value], index) => ({ OpportunityName_Id_c: { OptyNumber: String(index) }, ReportedHours_c: value })),
|
||||||
|
...[2, 8].map(value => ({ OpportunityName_Id_c: { OptyNumber: "multiple" }, ReportedHours_c: value }))
|
||||||
|
];
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
for (const [index, [, expected]] of examples.entries()) {
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.getSummary(String(index)).hasUnderFourHours, expected);
|
||||||
|
}
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").reportedHours, 10);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").hasUnderFourHours, true);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.getSummary("absent").state, "missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Action filter summary shares badge ownership and uncertainty rules", async () => {
|
||||||
|
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
app.authSessionState.principal = "me@example.invalid";
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true, actions: [{ owner: "other@example.invalid" }], hasNext: false });
|
||||||
|
const products = { items: [{ RevnId: "123" }] };
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", products);
|
||||||
|
await app.opportunityWorkloadActions.loadProducts(products);
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "missing");
|
||||||
|
app.authSessionState.principal = "other@example.invalid";
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
|
||||||
|
assert.equal(app.opportunityWorkloadActions.createBadge("A").attributes["data-state"], "matched");
|
||||||
|
app.authSessionState.principal = "";
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activity filters compose with existing search, status, owner and customer filters", () => {
|
||||||
|
const h = setup();
|
||||||
|
const items = ["A", "B"].map(OptyNumber => ({ OptyNumber, Name: `Test ${OptyNumber}`, StatusCode: "OPEN", ForecastGroup_c: "FORECAST",
|
||||||
|
CustomerAccount: { PartyUniqueName: "Synthetic customer", PartyId: "123" }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner" } }));
|
||||||
|
h.app.opportunitiesTableState.opportunitiesTableState.items = items;
|
||||||
|
h.app.opportunitiesFilters.resetCustomerFilter(items);
|
||||||
|
h.app.opportunitiesFilters.resetOwnerFilter(items);
|
||||||
|
h.sr.set("A", { state: "matched" });
|
||||||
|
h.sr.set("B", { state: "missing" });
|
||||||
|
h.click("sr", "missing");
|
||||||
|
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
|
||||||
|
assert.deepEqual(results(), ["A"]);
|
||||||
|
h.app.opportunitiesFilterState.tableSearch = "Test B";
|
||||||
|
assert.deepEqual(results(), []);
|
||||||
|
h.app.opportunitiesFilterState.tableSearch = "";
|
||||||
|
h.app.opportunitiesFilterState.selectedStatuses.clear();
|
||||||
|
assert.deepEqual(results(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activity filters have theme contrast, visible keyboard focus and responsive wrapping", () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
|
assert.match(css, /activity-filter-button:focus-visible/);
|
||||||
|
assert.match(css, /data-theme="dark"\] \.opportunities-extension-activity-filter-button/);
|
||||||
|
assert.match(css, /activity-filter\[hidden\] \{ display: none;/);
|
||||||
|
assert.match(css, /badge-filters \{\s*display: flex;[\s\S]*?flex-wrap: wrap;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SR and Act filters share sizing and typography with Status and Stage", () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
|
const shared = css.match(/\.opportunities-extension-form \.opportunities-extension-stage-filter-button,\s*\.opportunities-extension-form \.opportunities-extension-status-filter-button,\s*\.opportunities-extension-form \.opportunities-extension-activity-filter-button\s*\{([^}]+)\}/);
|
||||||
|
assert.ok(shared, "all filter badges use the same presentation rule");
|
||||||
|
for (const declaration of ["display: inline-flex;", "align-items: center;", "box-sizing: border-box;", "height: 28px;",
|
||||||
|
"padding: 4px 7px;", "border-radius: 18px;", "font-size: 11px;", "font-weight: 700;", "line-height: 18px;"]) {
|
||||||
|
assert.ok(shared[1].includes(declaration), declaration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desktop keeps fields and badge groups on one line, wrapping only below the compact breakpoint", () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
|
assert.match(css, /\.opportunities-extension-badge-filters \{ display: contents; \}/);
|
||||||
|
assert.match(css, /\[data-activity-filters="true"\] \{\s*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) repeat\(4, max-content\);/);
|
||||||
|
const breakpoint = css.indexOf("@media (max-width: 1350px)");
|
||||||
|
const fullRow = css.indexOf("grid-column: 1 / -1;", css.indexOf(".opportunities-extension-badge-filters { display: contents; }"));
|
||||||
|
assert.ok(breakpoint >= 0 && fullRow > breakpoint, "full-row badge toolbar is limited to smaller viewports");
|
||||||
|
assert.match(css, /activity-filter-controls \{ display: flex; align-items: center; flex-wrap: nowrap;/);
|
||||||
|
});
|
||||||
71
tests/activityProgress.test.cjs
Normal file
71
tests/activityProgress.test.cjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
test("activity indicators share Products styles, fade after 5s, retain failures and cancel stale timers", () => {
|
||||||
|
const nodes = [];
|
||||||
|
const timers = new Map();
|
||||||
|
let id = 0;
|
||||||
|
const app = loadApplication("content", {
|
||||||
|
document: { querySelector: () => ({ append: node => nodes.push(node) }), createElement: () => ({ attributes: {}, setAttribute(key, value) { this.attributes[key] = value; } }) },
|
||||||
|
setTimeout: (fn, delay) => { timers.set(++id, { fn, delay }); return id; }, clearTimeout: key => timers.delete(key)
|
||||||
|
});
|
||||||
|
const update = app.activityProgress.update;
|
||||||
|
update("sr", "running", 15, 58);
|
||||||
|
assert.equal(nodes[0].textContent, "Service Requests 15/58");
|
||||||
|
assert.equal(nodes[0].attributes["data-state"], "running");
|
||||||
|
assert.equal(nodes[0].className, "opportunities-extension-products-progress");
|
||||||
|
update("sr", "complete", 58, 58);
|
||||||
|
assert.equal([...timers.values()][0].delay, 5000);
|
||||||
|
[...timers.values()][0].fn();
|
||||||
|
assert.equal(nodes[0].attributes["data-fading"], "true");
|
||||||
|
const hide = [...timers.values()].find(timer => timer.delay === 300);
|
||||||
|
hide.fn();
|
||||||
|
assert.equal(nodes[0].hidden, true);
|
||||||
|
update("sr", "running", 0);
|
||||||
|
assert.equal(nodes[0].hidden, false);
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
update("actions", "complete-with-errors", 2, 3);
|
||||||
|
assert.equal(nodes[1].hidden, false);
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
update("sr", "error");
|
||||||
|
assert.equal(nodes[0].textContent, "Service Requests unavailable");
|
||||||
|
update("session", "running");
|
||||||
|
assert.equal(nodes[2].textContent, "SR session loading");
|
||||||
|
assert.equal(nodes[2].attributes["aria-busy"], "true");
|
||||||
|
update("session", "complete");
|
||||||
|
assert.equal(nodes[2].textContent, "SR session ready");
|
||||||
|
assert.equal(nodes[2].attributes["aria-busy"], "false");
|
||||||
|
assert.equal([...timers.values()].at(-1).delay, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CRM reports real page counts and Actions reports queued and completed requests", async () => {
|
||||||
|
const app = loadApplication("content");
|
||||||
|
const progress = [];
|
||||||
|
app.activityProgress.update = (...args) => progress.push(args);
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async (_token, _version, _active, onProgress) => {
|
||||||
|
onProgress(15, 20);
|
||||||
|
onProgress(20, 20);
|
||||||
|
return Array.from({ length: 20 }, () => ({}));
|
||||||
|
};
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
assert.deepEqual(progress, [["sr", "running", 0], ["sr", "running", 15, 20], ["sr", "running", 20, 20], ["sr", "complete", 20, 20]]);
|
||||||
|
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({});
|
||||||
|
const pending = [];
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = () => new Promise(resolve => pending.push(resolve));
|
||||||
|
app.opportunityWorkloadActions.loadProducts({ items: [{ RevnId: "1" }, { RevnId: "2" }] });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(progress.at(-1), ["actions", "running", 0, 2]);
|
||||||
|
pending[0]({ ok: true, actions: [], hasNext: false });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(progress.at(-1), ["actions", "running", 1, 2]);
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||||
|
pending[attempt]({ ok: false, error: "Synthetic failure" });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
assert.deepEqual(progress.at(-1), ["actions", "complete-with-errors", 2, 2]);
|
||||||
|
});
|
||||||
108
tests/activityRetries.test.cjs
Normal file
108
tests/activityRetries.test.cjs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const { response } = require("./support/characterization.cjs");
|
||||||
|
|
||||||
|
function setup(fetch) {
|
||||||
|
const app = loadApplication("content", { fetch });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
|
app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
app.activityProgress.update = () => {};
|
||||||
|
app.productsContentJobs.renderProductsProgress = () => {};
|
||||||
|
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
|
||||||
|
const actions = [];
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async message => {
|
||||||
|
actions.push(message);
|
||||||
|
return { ok: true, actions: [], hasNext: false };
|
||||||
|
};
|
||||||
|
async function cycle(id) {
|
||||||
|
app.productsContentState.currentProductsRequestId = id;
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "123" }], cachedAt: Date.now(), fromCache: false });
|
||||||
|
app.productActivities.begin(id, "synthetic");
|
||||||
|
await app.productActivities.complete(id);
|
||||||
|
}
|
||||||
|
return { app, actions, cycle };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("SR succeeds on fifth attempt, is shared in flight and reused across Reference/product reloads", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const h = setup(async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls < 5) throw new Error("Synthetic offline");
|
||||||
|
return response({ items: [], hasMore: false });
|
||||||
|
});
|
||||||
|
const first = h.app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.load("synthetic", 1), first);
|
||||||
|
assert.equal(await first, true);
|
||||||
|
assert.equal(calls, 5);
|
||||||
|
await h.cycle("current-quarter");
|
||||||
|
h.app.opportunitiesQueryState.periodRequestVersion += 1;
|
||||||
|
await h.cycle("next-quarter");
|
||||||
|
assert.equal(calls, 5);
|
||||||
|
assert.equal(h.actions.length, 2);
|
||||||
|
assert.equal(h.app.debugState.requestLog.filter(entry => entry.name === "resquestCrmServiceRequest").length, 5);
|
||||||
|
h.app.opportunityCrmServiceRequests.reset();
|
||||||
|
await h.cycle("explicit-refresh");
|
||||||
|
assert.equal(calls, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of [() => new Response("Denied", { status: 403 }), () => new Response("<html>Login</html>")]) {
|
||||||
|
test("SR exhausting five attempts blocks Actions and preserves the last completed timestamp", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const h = setup(async () => { calls += 1; return failure(); });
|
||||||
|
h.app.productsContentState.productsLastCachedAt = 123;
|
||||||
|
await h.cycle("failed");
|
||||||
|
assert.equal(calls, 5);
|
||||||
|
assert.equal(h.actions.length, 0);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("A").state, "error");
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 123);
|
||||||
|
h.app.authSessionState.principal = "synthetic@example.invalid";
|
||||||
|
assert.equal(h.app.opportunityWorkloadActions.getSummary("A").state, "error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("pagination retries only the failed SR page, with no duplicate records", async () => {
|
||||||
|
const offsets = [];
|
||||||
|
let failures = 0;
|
||||||
|
const h = setup(async (_url, options) => {
|
||||||
|
const { offset } = JSON.parse(options.body);
|
||||||
|
offsets.push(offset);
|
||||||
|
if (offset === 1 && failures++ < 2) return response({ invalid: true });
|
||||||
|
return response({ items: [{ SrNumber: `SR${offset}`, ReportedHours_c: 2, OpportunityName_Id_c: { OptyNumber: "A" } }],
|
||||||
|
offset, count: 1, hasMore: offset === 0 });
|
||||||
|
});
|
||||||
|
await h.cycle("paged");
|
||||||
|
assert.deepEqual(offsets, [0, 1, 1, 1]);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("A").reportedHours, 4);
|
||||||
|
assert.equal(h.actions.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Actions retries each workload up to five times and stops as soon as it succeeds", async () => {
|
||||||
|
const h = setup(async () => response({ items: [], hasMore: false }));
|
||||||
|
let attempts = 0;
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = async () => {
|
||||||
|
attempts += 1;
|
||||||
|
return attempts < 3 ? { ok: false, error: "Synthetic" } : { ok: true, actions: [], hasNext: false };
|
||||||
|
};
|
||||||
|
await h.cycle("success");
|
||||||
|
assert.equal(attempts, 3);
|
||||||
|
attempts = 0;
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = async () => { attempts += 1; throw new Error("Synthetic"); };
|
||||||
|
await h.cycle("failed-actions");
|
||||||
|
assert.equal(attempts, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an invalidated SR request does not continue retrying or launch Actions", async () => {
|
||||||
|
let reject, calls = 0;
|
||||||
|
const h = setup(() => { calls += 1; return new Promise((_resolve, fail) => { reject = fail; }); });
|
||||||
|
const pending = h.cycle("old");
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
h.app.opportunityCrmServiceRequests.reset();
|
||||||
|
h.app.productsContentState.currentProductsRequestId = "new";
|
||||||
|
reject(new Error("Synthetic"));
|
||||||
|
await pending;
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(h.actions.length, 0);
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ function setup(mockSalesToken = true) {
|
|||||||
const context = {
|
const context = {
|
||||||
document: {
|
document: {
|
||||||
createElement: (tag) => new Element(tag),
|
createElement: (tag) => new Element(tag),
|
||||||
|
createElementNS: (_, tag) => new Element(tag),
|
||||||
querySelectorAll: () => visibleAlerts
|
querySelectorAll: () => visibleAlerts
|
||||||
},
|
},
|
||||||
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
|
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
|
||||||
|
|||||||
68
tests/allOpportunityFilters.test.cjs
Normal file
68
tests/allOpportunityFilters.test.cjs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const app = loadApplication("content");
|
||||||
|
const normal = { OptyNumber: "normal", Name: "Synthetic", ForecastGroup_c: "FORECAST", StatusCode: "OPEN",
|
||||||
|
CustomerAccount: { PartyId: "1", PartyUniqueName: "Customer" }, Owner: { PersonFirstName: "Owner" } };
|
||||||
|
const items = [normal,
|
||||||
|
{ ...normal, OptyNumber: "stage", ForecastGroup_c: null },
|
||||||
|
{ ...normal, OptyNumber: "status", StatusCode: "UNLISTED" },
|
||||||
|
{ ...normal, OptyNumber: "customer", CustomerAccount: null },
|
||||||
|
{ ...normal, OptyNumber: "owner", Owner: null }];
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState.items = items;
|
||||||
|
app.opportunitiesFilters.resetCustomerFilter(items);
|
||||||
|
app.opportunitiesFilters.resetOwnerFilter(items);
|
||||||
|
return { app, items, result: () => Array.from(app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("all selected includes records with missing or unlisted dimensions", () => {
|
||||||
|
const h = setup();
|
||||||
|
assert.equal(h.result().length, h.items.length);
|
||||||
|
h.app.opportunitiesFilterState.tableSearch = "normal";
|
||||||
|
assert.deepEqual(h.result(), ["normal"], "search still restricts results");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial and empty selections remain restrictive, including WON_PENDING mapping", () => {
|
||||||
|
const h = setup();
|
||||||
|
const state = h.app.opportunitiesFilterState;
|
||||||
|
state.selectedStages.delete("SQL");
|
||||||
|
assert.equal(h.result().includes("stage"), false);
|
||||||
|
state.selectedStages.add("SQL");
|
||||||
|
state.selectedStatuses.delete("CLOSED");
|
||||||
|
assert.equal(h.result().includes("status"), false);
|
||||||
|
assert.equal(h.app.opportunitiesFilters.matchesSelectedStatus("WON_PENDING"), true);
|
||||||
|
state.selectedStatuses.clear();
|
||||||
|
assert.deepEqual(h.result(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("customer and owner exclusions apply only when a narrower selection is made", () => {
|
||||||
|
const h = setup();
|
||||||
|
const state = h.app.opportunitiesFilterState;
|
||||||
|
state.selectedCustomerKeys.clear();
|
||||||
|
assert.deepEqual(h.result(), []);
|
||||||
|
state.selectedCustomerKeys.add("party:1");
|
||||||
|
assert.equal(h.result().includes("customer"), true);
|
||||||
|
state.selectedOwnerKeys.clear();
|
||||||
|
assert.deepEqual(h.result(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Owner offers one dash option for unassigned records and supports selecting it alone", () => {
|
||||||
|
const h = setup();
|
||||||
|
for (const Owner of [undefined, {}, "", " ", { PersonFirstName: " " }]) {
|
||||||
|
h.items.push({ ...h.items[0], OptyNumber: `missing-${h.items.length}`, Owner });
|
||||||
|
}
|
||||||
|
const options = Array.from(h.app.opportunitiesFormatters.getOwnerOptions());
|
||||||
|
const missing = options.filter(option => option.label === "-");
|
||||||
|
assert.equal(missing.length, 1);
|
||||||
|
assert.equal(missing[0].key, "owner:unassigned");
|
||||||
|
h.app.opportunitiesFilterState.selectedOwnerKeys = new Set([missing[0].key]);
|
||||||
|
assert.equal(h.result().length, 6);
|
||||||
|
assert.equal(h.result().includes("normal"), false);
|
||||||
|
h.app.opportunitiesFilterState.selectedOwnerKeys = new Set(options.map(option => option.key));
|
||||||
|
assert.equal(h.result().length, h.items.length);
|
||||||
|
h.app.opportunitiesFilterState.selectedOwnerKeys.delete(missing[0].key);
|
||||||
|
assert.equal(h.result().includes("owner"), false);
|
||||||
|
assert.equal(h.result().length, 4);
|
||||||
|
});
|
||||||
@@ -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]));
|
||||||
|
|||||||
113
tests/badgeIcons.test.cjs
Normal file
113
tests/badgeIcons.test.cjs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { loadApplication, composeScript } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
const root = path.join(__dirname, "..");
|
||||||
|
class Element {
|
||||||
|
constructor(tag) {
|
||||||
|
this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = "";
|
||||||
|
this.classList = { toggle() {} };
|
||||||
|
}
|
||||||
|
set textContent(value) { this.text = String(value); this.children = []; }
|
||||||
|
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
|
||||||
|
append(...children) { this.children.push(...children); }
|
||||||
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
|
addEventListener(key, callback) { this.listeners[key] = callback; }
|
||||||
|
}
|
||||||
|
function setup() {
|
||||||
|
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), getElementById: () => null,
|
||||||
|
createElementNS: (namespace, tag) => Object.assign(new Element(tag), { namespace }) } });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
app.activityProgress.update = () => {};
|
||||||
|
app.authSessionState.principal = "me@example.invalid";
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
function checkIcon(badge, kind, value) {
|
||||||
|
assert.equal(badge.textContent, value);
|
||||||
|
const images = badge.children.filter(child => child.tag === "svg");
|
||||||
|
assert.equal(images.length, 1);
|
||||||
|
const icon = images[0];
|
||||||
|
assert.equal(icon.namespace, "http://www.w3.org/2000/svg");
|
||||||
|
assert.equal(icon.attributes.viewBox, "0 0 24 24");
|
||||||
|
assert.equal(icon.attributes.fill, "none");
|
||||||
|
assert.equal(icon.attributes.stroke, "currentColor");
|
||||||
|
assert.equal(icon.attributes["stroke-width"], "2.25");
|
||||||
|
assert.equal(icon.attributes["stroke-linecap"], "round");
|
||||||
|
assert.equal(icon.attributes["stroke-linejoin"], "round");
|
||||||
|
assert.equal(icon.attributes["data-badge-icon"], kind);
|
||||||
|
assert.equal(icon.attributes["aria-hidden"], "true");
|
||||||
|
assert.equal(icon.attributes.focusable, "false");
|
||||||
|
assert.equal(icon.attributes.width, "16");
|
||||||
|
assert.equal(icon.attributes.height, "16");
|
||||||
|
assert.ok(icon.children.length >= 3);
|
||||||
|
assert.ok(icon.children.every(child => ["path", "rect", "polyline"].includes(child.tag)));
|
||||||
|
assert.ok(icon.children.every(child => child.namespace === icon.namespace));
|
||||||
|
assert.equal(badge.children.some(child => child.tag === "img"), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("consistent inline vectors replace raster icons without parentheses or lost tooltips", async () => {
|
||||||
|
const app = setup();
|
||||||
|
const products = { items: [1, 2, 3].map(RampMonths_c => ({ RampMonths_c, RevnId: "123" })) };
|
||||||
|
const count = app.productsDetails.createProductsCountButton("TEST", products);
|
||||||
|
checkIcon(count, "products", "3");
|
||||||
|
assert.equal(count.title, "3 products");
|
||||||
|
assert.equal(typeof count.listeners.click, "function");
|
||||||
|
const ramp = app.productsDetails.createProductsRampBadge(products);
|
||||||
|
checkIcon(ramp, "ramp", "1 | 2 | 3");
|
||||||
|
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
|
||||||
|
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
|
||||||
|
checkIcon(missingRamp, "ramp", "N/D");
|
||||||
|
assert.equal(missingRamp.attributes["data-missing"], "true");
|
||||||
|
|
||||||
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
|
||||||
|
{ SrNumber: "SR-TEST", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "TEST" } }
|
||||||
|
];
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic", app.opportunitiesQueryState.periodRequestVersion);
|
||||||
|
const sr = app.opportunityCrmServiceRequests.createBadge("TEST");
|
||||||
|
checkIcon(sr, "sr", "4h");
|
||||||
|
assert.equal(sr.title, "Whether a Service Request has been created for the opportunities.");
|
||||||
|
assert.equal(typeof sr.listeners.click, "function");
|
||||||
|
checkIcon(app.opportunityCrmServiceRequests.createBadge("OTHER"), "sr", "0h+");
|
||||||
|
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("TEST", products);
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true, hasNext: false, actions: [
|
||||||
|
{ id: 1, owner: "me@example.invalid", name: "My action" },
|
||||||
|
{ id: 2, owner: "other@example.invalid", name: "Other action" }
|
||||||
|
] });
|
||||||
|
await app.opportunityWorkloadActions.loadProducts(products);
|
||||||
|
const actions = app.opportunityWorkloadActions.createBadge("TEST");
|
||||||
|
checkIcon(actions, "actions", "2");
|
||||||
|
assert.equal(actions.attributes["data-state"], "matched");
|
||||||
|
assert.match(actions.title, /My action/);
|
||||||
|
assert.match(actions.title, /Other action/);
|
||||||
|
app.currentUser.allowsCrmActivities = () => false;
|
||||||
|
assert.equal(app.opportunityWorkloadActions.createBadge("TEST"), null);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vector shapes and their license ship in both distributions without raster images or requests", () => {
|
||||||
|
const source = composeScript("content");
|
||||||
|
const module = fs.readFileSync(path.join(root, "src/features/opportunities/badge-content.js"), "utf8");
|
||||||
|
assert.ok(source.includes(module));
|
||||||
|
assert.doesNotMatch(source, /opportunityBadgeImages|data:image\/png;base64/);
|
||||||
|
for (const target of ["chromium", "firefox"]) {
|
||||||
|
const bundle = fs.readFileSync(path.join(root, "dist", target, "content.js"), "utf8");
|
||||||
|
assert.ok(bundle.includes(module));
|
||||||
|
assert.ok(bundle.includes("Copyright (c) 2026 Lucide Icons and Contributors"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("icon styles preserve uniform dimensions and provide light, dark and active contrast", () => {
|
||||||
|
const css = fs.readFileSync(path.join(root, "src/features/opportunities/styles.css"), "utf8");
|
||||||
|
const icon = css.match(/\.opportunities-extension-badge-icon \{([^}]+)\}/)[1];
|
||||||
|
for (const rule of ["order: -1;", "flex: 0 0 16px;", "width: 16px;", "height: 16px;", "color: inherit;", "stroke: currentColor;", "stroke-width: 2.25;"]) {
|
||||||
|
assert.ok(icon.includes(rule), rule);
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(icon, /filter:|mix-blend-mode:/);
|
||||||
|
assert.match(css, /gap: 4px;\s*height: 20px;/);
|
||||||
|
assert.match(css, /cell-actions > \.opportunities-extension-crm-sr-badge \{\s*display: inline-flex;\s*align-items: center;\s*justify-content: flex-start;/);
|
||||||
|
});
|
||||||
@@ -40,8 +40,8 @@ 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.3", permissions: ["cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
|
||||||
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.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"
|
||||||
@@ -93,7 +93,7 @@ test("opportunity query preserves fields, record-set OR, date AND, and paginatio
|
|||||||
{ op: "$or", criteria: types.map(value => ({ op: "$eq", attribute: "RecordSet", value })) },
|
{ 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: range.startCloseDate, value2: range.endCloseDate, dynamicDate: false }
|
||||||
] }, 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: [], 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
|
||||||
});
|
});
|
||||||
@@ -183,6 +183,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;
|
||||||
|
|||||||
73
tests/consumerTokenAuthorizationRecovery.test.cjs
Normal file
73
tests/consumerTokenAuthorizationRecovery.test.cjs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
|
const tokens = [
|
||||||
|
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
|
||||||
|
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } },
|
||||||
|
{ tokenService: "WWB-TS", uiaasHeader: { value: "synthetic-actions" } }
|
||||||
|
];
|
||||||
|
const request = { type: "opportunitiesExtension.requestConsumerTokenService" };
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
for (const status of [401, 403]) {
|
||||||
|
test(`${family}: HTTP ${status} opens an inactive SPA session and closes it after valid JSON`, async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const h = loadBackground({ family, fetch: async (_url, options) => {
|
||||||
|
attempts += 1;
|
||||||
|
assert.equal(options.credentials, "include");
|
||||||
|
if (attempts === 1) return response({ error: "Session unavailable" }, status);
|
||||||
|
assert.equal(h.tabs.length, 1, "session tab must open before retrying");
|
||||||
|
return response(tokens);
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch(request);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(attempts, 2);
|
||||||
|
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].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.deepEqual(h.removedTabs, [91]);
|
||||||
|
assert.equal(result.debugRequests[0].httpStatus, status);
|
||||||
|
assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/);
|
||||||
|
assert.equal(result.debugRequests[1].metadata.sessionBootstrapState, "closed-after-valid-json");
|
||||||
|
assert.equal(result.authorizations.workloadActions.value, "synthetic-actions");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test(`${family}: persistent 403 stops after five attempts and removes the auxiliary tab`, async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const h = loadBackground({ family, fetch: async () => { attempts += 1; return response({}, 403); } });
|
||||||
|
const result = await h.dispatch(request);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(attempts, 5);
|
||||||
|
assert.equal(h.tabs.length, 1);
|
||||||
|
assert.deepEqual(h.removedTabs, [91]);
|
||||||
|
assert.match(result.error, /403 after 5 attempts/);
|
||||||
|
assert.equal(result.debugRequests.at(-1).metadata.retryLimitReached, "true");
|
||||||
|
assert.equal(result.debugRequests.at(-1).metadata.sessionBootstrapState, "closed-after-retry-finished");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mixed 403/HTML recovery shares one tab and the existing retry budget", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const h = loadBackground({ fetch: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) return response({}, 403);
|
||||||
|
if (attempts === 2) return new Response("<html>Login</html>");
|
||||||
|
return response(tokens);
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch(request);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(attempts, 3);
|
||||||
|
assert.equal(h.tabs.length, 1);
|
||||||
|
assert.deepEqual(h.removedTabs, [91]);
|
||||||
|
assert.equal(result.debugRequests[1].metadata.sessionBootstrapState, "waiting-for-valid-session");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unrelated HTTP failures do not launch the SPA session page", async () => {
|
||||||
|
const h = loadBackground({ fetch: async () => response({}, 500) });
|
||||||
|
const result = await h.dispatch(request);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.debugRequests.length, 1);
|
||||||
|
assert.equal(h.tabs.length, 0);
|
||||||
|
});
|
||||||
@@ -3,7 +3,8 @@ const { test } = require("node:test");
|
|||||||
const { loadApplication } = require("./support/application.cjs");
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
const response = data => new Response(JSON.stringify(data), { status: 200 });
|
const response = data => new Response(JSON.stringify(data), { status: 200 });
|
||||||
function setup(fetch) {
|
function setup(fetch) {
|
||||||
const document = { createElement: () => ({ attributes: {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
|
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
|
||||||
|
document.createElementNS = (_, tag) => document.createElement(tag);
|
||||||
const app = loadApplication("content", { fetch, document, Intl, Date });
|
const app = loadApplication("content", { fetch, document, Intl, Date });
|
||||||
app.currentUser.allowsCrmActivities = () => true;
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
app.debugContentView.renderDebugPanel = () => {};
|
app.debugContentView.renderDebugPanel = () => {};
|
||||||
@@ -14,6 +15,27 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
|
|||||||
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
|
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
|
||||||
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
|
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
|
||||||
|
|
||||||
|
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
|
||||||
|
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/D"], [[4, null], "N/D"], [[""], "N/D"], [["invalid"], "N/D"]]) {
|
||||||
|
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, expected);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "0h");
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").title, "Whether a Service Request has been created for the opportunities.");
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").title, "Whether a Service Request has been created for the opportunities.");
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").attributes["data-state"], "matched");
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").attributes["data-state"], "missing");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -28,7 +50,7 @@ test("CRM fetch is called without the environment object as its receiver", async
|
|||||||
assert.equal(items.length, 2);
|
assert.equal(items.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("CRM query preserves payload, paginates before opportunities, uses tokenrelay Bearer and logs each page", async () => {
|
test("CRM query preserves payload and pagination after opportunities, uses tokenrelay Bearer and logs each page", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const app = setup(async (url, options) => {
|
const app = setup(async (url, options) => {
|
||||||
const payload = JSON.parse(options.body);
|
const payload = JSON.parse(options.body);
|
||||||
@@ -40,11 +62,12 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
|
|||||||
app.productsContentJobs.renderProductsProgress = () => {};
|
app.productsContentJobs.renderProductsProgress = () => {};
|
||||||
app.productsContentJobs.requestOpptyProductsInBackground = () => {};
|
app.productsContentJobs.requestOpptyProductsInBackground = () => {};
|
||||||
app.fusionOpportunities.requestOpportunitiesPage = async () => {
|
app.fusionOpportunities.requestOpportunitiesPage = async () => {
|
||||||
assert.equal(calls.length, 2, "all CRM pages must finish first");
|
assert.equal(calls.length, 0, "CRM must not run before product loading");
|
||||||
return [{ OptyNumber: "TEST-1" }];
|
return [{ OptyNumber: "TEST-1" }];
|
||||||
};
|
};
|
||||||
await app.opportunitiesController.requestOpportunities("synthetic-token", "Current Quarter", {}, 1);
|
await app.opportunitiesController.requestOpportunities("synthetic-token", "Current Quarter", {}, 1);
|
||||||
assert.equal(states.at(-1).status, "ready");
|
assert.equal(states.at(-1).status, "ready");
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic-token", 1);
|
||||||
assert.deepEqual(calls.map(call => call.payload.offset), [0, 1]);
|
assert.deepEqual(calls.map(call => call.payload.offset), [0, 1]);
|
||||||
const { payload, options, url } = calls[0];
|
const { payload, options, url } = calls[0];
|
||||||
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries");
|
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries");
|
||||||
@@ -61,7 +84,7 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
|
|||||||
{ 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"],
|
||||||
@@ -75,15 +98,19 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
|
|||||||
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");
|
||||||
assert.equal(found.textContent, "\u2705 SR");
|
assert.equal(found.textContent, "0h");
|
||||||
assert.equal(found.attributes["data-state"], "matched");
|
assert.equal(found.attributes["data-state"], "matched");
|
||||||
for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"])
|
for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"])
|
||||||
assert.ok(found.attributes["aria-label"].includes(detail));
|
assert.ok(found.attributes["aria-label"].includes(detail));
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "0h");
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
|
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
|
||||||
app.opportunityCrmServiceRequests.reset();
|
app.opportunityCrmServiceRequests.reset();
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST-1"), null);
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST-1"), null);
|
||||||
@@ -107,7 +134,8 @@ test("CRM failures do not mislabel missing SRs or block the opportunity list", a
|
|||||||
app.fusionOpportunities.requestOpportunitiesPage = async () => { loaded = true; return []; };
|
app.fusionOpportunities.requestOpportunitiesPage = async () => { loaded = true; return []; };
|
||||||
await app.opportunitiesController.requestOpportunities("token", "Current Quarter", {}, 1);
|
await app.opportunitiesController.requestOpportunities("token", "Current Quarter", {}, 1);
|
||||||
assert.equal(loaded, true);
|
assert.equal(loaded, true);
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST").textContent, "SR ?");
|
await app.opportunityCrmServiceRequests.load("token", 1);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST").textContent, "?");
|
||||||
assert.equal(app.debugState.requestLog.at(-1).status, "failed");
|
assert.equal(app.debugState.requestLog.at(-1).status, "failed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -122,8 +150,8 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
|
|||||||
await app.opportunityCrmServiceRequests.load("token", 2);
|
await app.opportunityCrmServiceRequests.load("token", 2);
|
||||||
resolveOld(response({ items: [sr("OLD")], hasMore: true, count: 1, offset: 0 }));
|
resolveOld(response({ items: [sr("OLD")], hasMore: true, count: 1, offset: 0 }));
|
||||||
await old;
|
await old;
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "\u2705 SR");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "\u26d4 SR");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
|
||||||
assert.equal(app.debugState.requestLog.length, 1);
|
assert.equal(app.debugState.requestLog.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,8 +172,8 @@ test("Reference changes share pending pagination and reuse completed CRM results
|
|||||||
app.opportunitiesQueryState.periodRequestVersion = 3;
|
app.opportunitiesQueryState.periodRequestVersion = 3;
|
||||||
await app.opportunityCrmServiceRequests.load("token", 3);
|
await app.opportunityCrmServiceRequests.load("token", 3);
|
||||||
assert.equal(calls, 2);
|
assert.equal(calls, 2);
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "\u2705 SR");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "0h");
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "\u2705 SR");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "0h");
|
||||||
app.opportunityCrmServiceRequests.reset();
|
app.opportunityCrmServiceRequests.reset();
|
||||||
await app.opportunityCrmServiceRequests.load("token", 3);
|
await app.opportunityCrmServiceRequests.load("token", 3);
|
||||||
assert.equal(calls, 3);
|
assert.equal(calls, 3);
|
||||||
|
|||||||
327
tests/crmSrDetails.test.cjs
Normal file
327
tests/crmSrDetails.test.cjs
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
|
const type = "opportunitiesExtension.requestInternalServiceRequest";
|
||||||
|
|
||||||
|
test("internal SR form lookups use management proxy, Session and parent SrId", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ fetch: async (url, options) => {
|
||||||
|
calls.push({ url: new URL(url), options });
|
||||||
|
return response({ items: String(url).includes("/lookups?")
|
||||||
|
? [{ code: "OCI", name: "Oracle Cloud" }]
|
||||||
|
: [{ Id: 123, RecordName: "Review", Description_c: "Review the architecture" }] });
|
||||||
|
} });
|
||||||
|
const platform = await h.dispatch({ type: "opportunitiesExtension.requestLookupTypePlatform" });
|
||||||
|
assert.equal(platform.ok, true);
|
||||||
|
assert.equal(platform.items[0].code, "OCI");
|
||||||
|
const service = await h.dispatch({ type: "opportunitiesExtension.requestInternalSrServices", parentServiceId: "456", emailAddress: "user+test@example.invalid" });
|
||||||
|
assert.equal(service.ok, true);
|
||||||
|
assert.equal(service.items[0].Id, "123");
|
||||||
|
assert.equal(service.items[0].Description_c, "Review the architecture");
|
||||||
|
assert.equal(calls[1].url.searchParams.get("parentServiceId"), "456");
|
||||||
|
assert.equal(calls[1].url.searchParams.get("emailAddress"), "user+test@example.invalid");
|
||||||
|
assert.equal(calls[1].url.searchParams.get("serviceType"), "INTERNAL");
|
||||||
|
for (const call of calls) {
|
||||||
|
assert.match(call.url.pathname, /SeaaS_SR_management.*oalsetSeaaSOKECustomRestAPI/);
|
||||||
|
assert.equal(call.options.headers.Authorization, "Session");
|
||||||
|
assert.equal(call.options.credentials, "include");
|
||||||
|
}
|
||||||
|
assert.equal((await h.dispatch({ type: "opportunitiesExtension.requestInternalSrServices", parentServiceId: "bad", emailAddress: "user@example.invalid" })).ok, false);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preparation reuses an existing unexpired cookie pair without navigation or API calls", async () => {
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const h = loadBackground({
|
||||||
|
fetch: async () => assert.fail("No API calls during preparation"),
|
||||||
|
cookies: () => [1, 2].map(part => ({ name: `ORA_OCIS_CG_SESSION_idcs-existing_${domain}_${part}`, value: `secret-${part}`, domain }))
|
||||||
|
});
|
||||||
|
const result = await h.dispatch({ type: "opportunitiesExtension.prepareSrSession" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(h.tabs.length, 0);
|
||||||
|
assert.equal(h.removedTabs.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preparation closes immediately on the second cookie event even while getAll is pending", { timeout: 2000 }, async () => {
|
||||||
|
const fs = require("node:fs"), vm = require("node:vm"), path = require("node:path");
|
||||||
|
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/services/fusion/sr-details.js"), "utf8")}\ncreateFusionSrDetails`);
|
||||||
|
const listeners = new Set();
|
||||||
|
let reads = 0, closed = 0;
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const cookie = part => ({ name: `ORA_OCIS_CG_SESSION_idcs-new_${domain}_${part}`, value: `secret-${part}`, domain, storeId: "store" });
|
||||||
|
const emit = change => { for (const listener of listeners) listener(change); };
|
||||||
|
const api = factory({
|
||||||
|
fetchJson: async () => assert.fail("No API calls during preparation"),
|
||||||
|
cookieChanges: { addListener: fn => listeners.add(fn), removeListener: fn => listeners.delete(fn) },
|
||||||
|
cookiesGetAll: () => ++reads === 1 ? Promise.resolve([]) : new Promise(() => {}),
|
||||||
|
tabsCreate: async () => {
|
||||||
|
setImmediate(() => {
|
||||||
|
emit({ cookie: cookie(1) });
|
||||||
|
emit({ cookie: { ...cookie(2), storeId: "other" } });
|
||||||
|
assert.equal(closed, 0);
|
||||||
|
emit({ cookie: cookie(2), removed: true });
|
||||||
|
assert.equal(closed, 0);
|
||||||
|
emit({ cookie: cookie(2) });
|
||||||
|
});
|
||||||
|
return { id: 7 };
|
||||||
|
},
|
||||||
|
tabsRemove: async id => { assert.equal(id, 7); closed += 1; },
|
||||||
|
supportsFirefoxCookieStoreTabs: () => false,
|
||||||
|
wait: () => new Promise(() => {})
|
||||||
|
});
|
||||||
|
assert.equal((await api.prepareSession({ tab: { cookieStoreId: "store" } })).ok, true);
|
||||||
|
assert.equal(closed, 1);
|
||||||
|
assert.equal(listeners.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit preparation opens SeaaS initiation and observes cookies without any API requests", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
let reads = 0;
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const pair = [1, 2].map(part => ({ name: `ORA_OCIS_CG_SESSION_idcs-new_${domain}_${part}`, value: `secret-${part}`, domain }));
|
||||||
|
const h = loadBackground({ fetch: async () => {
|
||||||
|
calls += 1;
|
||||||
|
assert.fail("Opening the main modal must not trigger any session-dependent API request");
|
||||||
|
}, cookies: () => {
|
||||||
|
reads += 1;
|
||||||
|
if (reads === 1) return [];
|
||||||
|
assert.equal(h.tabs.length, 1);
|
||||||
|
if (reads === 2) return pair.slice(0, 1);
|
||||||
|
if (reads === 3) return pair.map(cookie => ({ ...cookie, expirationDate: 1 }));
|
||||||
|
return pair;
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type: "opportunitiesExtension.prepareSrSession" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(reads, 4);
|
||||||
|
assert.deepEqual(result.debugRequests, []);
|
||||||
|
assert.ok(!JSON.stringify(result).includes("secret"));
|
||||||
|
assert.equal(h.tabs[0].active, false);
|
||||||
|
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/");
|
||||||
|
assert.deepEqual(h.removedTabs, [91]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("proactive preparation times out without issuing lookup probes", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const h = loadBackground({ messageTimeout: 10000, fetch: async () => { calls += 1; assert.fail("No probes allowed"); } });
|
||||||
|
const result = await h.dispatch({ type: "opportunitiesExtension.prepareSrSession" });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.match(result.error, /session could not be established/);
|
||||||
|
assert.deepEqual(h.removedTabs, [91]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cookie change wakes recovery without waiting for a polling timer and removes its listener", { timeout: 2000 }, async () => {
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
const path = require("node:path");
|
||||||
|
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/services/fusion/sr-details.js"), "utf8")}\ncreateFusionSrDetails`);
|
||||||
|
const listeners = new Set();
|
||||||
|
let ready = false;
|
||||||
|
let calls = 0;
|
||||||
|
let closed = 0;
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const cookies = [1, 2].map(part => ({ name: `ORA_OCIS_CG_SESSION_idcs-new_${domain}_${part}`, value: `secret-${part}`, domain, storeId: "store" }));
|
||||||
|
const api = factory({
|
||||||
|
cookieChanges: { addListener: fn => listeners.add(fn), removeListener: fn => listeners.delete(fn) },
|
||||||
|
cookiesGetAll: async () => ready ? cookies : [],
|
||||||
|
tabsCreate: async () => {
|
||||||
|
setImmediate(() => {
|
||||||
|
ready = true;
|
||||||
|
for (const listener of listeners) listener({ cookie: cookies[1] });
|
||||||
|
});
|
||||||
|
return { id: 7 };
|
||||||
|
},
|
||||||
|
tabsRemove: async () => { closed += 1; },
|
||||||
|
supportsFirefoxCookieStoreTabs: () => false,
|
||||||
|
wait: () => new Promise(() => {})
|
||||||
|
});
|
||||||
|
const result = await api.requestLookupWithSession(async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (!ready) { const error = new Error("Unauthorized"); error.httpStatus = 401; throw error; }
|
||||||
|
return { ok: true, items: [] };
|
||||||
|
}, {}, { tab: { cookieStoreId: "store" } });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(closed, 1);
|
||||||
|
assert.equal(listeners.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renewed matching cookie fragments trigger an early retry without leaking session values", async () => {
|
||||||
|
let reads = 0;
|
||||||
|
let calls = 0;
|
||||||
|
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const cookie = (id, part, value) => ({ name: `ORA_OCIS_CG_SESSION_idcs-${id}_${domain}_${part}`, value, domain, path: "/", session: true });
|
||||||
|
const h = loadBackground({
|
||||||
|
cookies: () => {
|
||||||
|
reads += 1;
|
||||||
|
if (reads < 3) return [cookie("old", 1, "old-secret-1"), cookie("old", 2, "old-secret-2")];
|
||||||
|
if (reads === 3) return [cookie("new", 1, "new-secret-1"), cookie("other", 2, "wrong-pair")];
|
||||||
|
return [cookie("new", 1, "new-secret-1"), cookie("new", 2, "new-secret-2")];
|
||||||
|
},
|
||||||
|
fetch: async (_, options) => {
|
||||||
|
calls += 1;
|
||||||
|
assert.equal(options.credentials, "include");
|
||||||
|
assert.equal(options.headers.Cookie, undefined);
|
||||||
|
if (calls === 1) return response({}, 401);
|
||||||
|
assert.equal(reads, 4, "retry immediately when both renewed parts match, not on a partial pair");
|
||||||
|
return response({ items: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupLanguage" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.ok(!JSON.stringify(result).includes("secret"));
|
||||||
|
assert.ok(h.cookieQueries.every(query => query.domain === domain && query.storeId === "test-store"));
|
||||||
|
assert.deepEqual(h.removedTabs, [91]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: simultaneous lookup 401s share an inactive session tab and close it after successful retries`, async () => {
|
||||||
|
const counts = new Map();
|
||||||
|
const h = loadBackground({ family, fetch: async url => {
|
||||||
|
const count = (counts.get(url) || 0) + 1;
|
||||||
|
counts.set(url, count);
|
||||||
|
return count < 3 ? response({}, 401) : response({ items: [] });
|
||||||
|
} });
|
||||||
|
const results = await Promise.all(["PrimaryPillar", "Language", "TechPlatform", "OpportunityBusiness"].map(name =>
|
||||||
|
h.dispatch({ type: `opportunitiesExtension.requestSrLookup${name}`, emailAddress: "test@example.invalid" })));
|
||||||
|
assert.ok(results.every(result => result.ok));
|
||||||
|
assert.ok(results.every(result => result.debugRequests.length === 3));
|
||||||
|
assert.equal(h.tabs.length, 1);
|
||||||
|
assert.equal(h.tabs[0].active, false);
|
||||||
|
assert.equal(h.tabs[0].windowId, 3);
|
||||||
|
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/");
|
||||||
|
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
|
||||||
|
assert.deepEqual(h.removedTabs, [91]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("lookup authentication timeout closes the temporary tab and returns a clear error", async () => {
|
||||||
|
const h = loadBackground({ messageTimeout: 10000, fetch: async () => response({}, 401) });
|
||||||
|
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupLanguage" });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /session could not be established/);
|
||||||
|
assert.equal(h.tabs.length, 1);
|
||||||
|
assert.deepEqual(h.removedTabs, [91]);
|
||||||
|
});
|
||||||
|
test("SR language, platform and services use fixed Session endpoints and dynamic email", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return response({ items: url.includes("/services?")
|
||||||
|
? [{ Id: 123, RecordName: "Consumption", Abstract_c: "Service summary" }]
|
||||||
|
: [{ code: "CODE", name: "Label", enabledFlag: "Y", displaySequence: 1 }] });
|
||||||
|
} });
|
||||||
|
for (const [name, lookup] of [["requestSrLookupLanguage", "SENGG_LANGUAGE"], ["requestSrLookupTechPlatform", "SENGG_TECH_PLATFORM"]]) {
|
||||||
|
const result = await h.dispatch({ type: `opportunitiesExtension.${name}` });
|
||||||
|
assert.deepEqual(result.items, [{ code: "CODE", name: "Label" }]);
|
||||||
|
assert.equal(new URL(calls.at(-1).url).searchParams.get("lookupType"), lookup);
|
||||||
|
assert.equal(result.debugRequests[0].name, name);
|
||||||
|
}
|
||||||
|
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupOpportunityBusiness", emailAddress: "user+test@example.invalid" });
|
||||||
|
assert.deepEqual(result.items, [{ Id: "123", RecordName: "Consumption", Abstract_c: "Service summary" }]);
|
||||||
|
const url = new URL(calls.at(-1).url);
|
||||||
|
assert.equal(url.searchParams.get("emailAddress"), "user+test@example.invalid");
|
||||||
|
assert.equal(url.searchParams.get("salesObject"), "OPPORTUNITY");
|
||||||
|
assert.equal(url.searchParams.get("serviceType"), "BUSINESS");
|
||||||
|
assert.ok(calls.every(call => call.options.headers.Authorization === "Session" && call.options.method === "GET"));
|
||||||
|
assert.equal((await h.dispatch({ type: "opportunitiesExtension.requestSrLookupOpportunityBusiness" })).ok, false);
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
});
|
||||||
|
test("Primary pillar lookup uses Session, orders enabled name/code options and rejects invalid data", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return response({ items: [
|
||||||
|
{ code: "B", name: "Second", displaySequence: 20, enabledFlag: "Y" },
|
||||||
|
{ code: "N", name: "Disabled", displaySequence: 1, enabledFlag: "N" },
|
||||||
|
{ code: "A", name: "First", displaySequence: 10, enabledFlag: "Y" }
|
||||||
|
] });
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(result.items, [{ code: "A", name: "First" }, { code: "B", name: "Second" }]);
|
||||||
|
assert.ok(calls[0].url.includes("SeaaS_initiation/live;profile=PROD"));
|
||||||
|
assert.ok(calls[0].url.endsWith("lookupType=SENGG_PRIMARY_PILLAR&type=STANDARD"));
|
||||||
|
assert.equal(calls[0].options.headers.Authorization, "Session");
|
||||||
|
assert.equal(calls[0].options.method, "GET");
|
||||||
|
for (const fetch of [async () => response({ items: [{}] }), async () => response({}, 401)]) {
|
||||||
|
assert.equal((await loadBackground({ fetch, messageTimeout: 10000 }).dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" })).ok, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: 401 is returned to the caller without opening tabs`, async () => {
|
||||||
|
let count = 0;
|
||||||
|
const h = loadBackground({ family, fetch: async () => ++count < 3 ? response({}, 401) : response({ items: [] }) });
|
||||||
|
const result = await h.dispatch({ type, srId: "123" });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.httpStatus, 401);
|
||||||
|
assert.equal(count, 1);
|
||||||
|
assert.equal(h.tabs.length, 0);
|
||||||
|
assert.deepEqual(h.removedTabs, []);
|
||||||
|
assert.deepEqual(result.debugRequests.map(entry => entry.httpStatus), [401]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("concurrent 401 responses do not cause background tab recovery", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const h = loadBackground({ fetch: async url => {
|
||||||
|
if (++calls <= 2) return response({}, 401);
|
||||||
|
const id = new URL(url).searchParams.get("q");
|
||||||
|
return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: [{ Title: id }] }] });
|
||||||
|
} });
|
||||||
|
const results = await Promise.all([h.dispatch({ type, srId: "1" }), h.dispatch({ type, srId: "2" })]);
|
||||||
|
assert.equal(h.tabs.length, 0);
|
||||||
|
assert.deepEqual(h.removedTabs, []);
|
||||||
|
assert.ok(results.every(result => result.httpStatus === 401));
|
||||||
|
});
|
||||||
|
test("background never creates recovery tabs after an error", async () => {
|
||||||
|
for (const status of [401, 500]) {
|
||||||
|
let count = 0;
|
||||||
|
const h = loadBackground({ fetch: async () => response({}, ++count === 1 ? 401 : status) });
|
||||||
|
const result = await h.dispatch({ type, srId: "1" });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(count, 1);
|
||||||
|
assert.deepEqual(h.removedTabs, []);
|
||||||
|
}
|
||||||
|
const h = loadBackground({ fetch: async () => response({}, 403) });
|
||||||
|
assert.equal((await h.dispatch({ type, srId: "1" })).ok, false);
|
||||||
|
assert.equal(h.tabs.length, 0);
|
||||||
|
});
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const children = [{ SrId: 123, SrNumber: "SR123", Title: "Synthetic" }];
|
||||||
|
const h = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: children }] });
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type, srId: "987", token: "secret-test" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(result.items, children);
|
||||||
|
const url = new URL(calls[0].url);
|
||||||
|
assert.equal(url.hostname, "comcipapic-oalprod.integration.ocp.oraclecloud.com");
|
||||||
|
assert.equal(url.searchParams.get("q"), "SrId=987");
|
||||||
|
assert.equal(url.searchParams.get("onlyData"), "true");
|
||||||
|
assert.ok(url.searchParams.get("fields").includes("ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c"));
|
||||||
|
assert.equal(calls[0].options.method, "GET");
|
||||||
|
assert.equal(calls[0].options.headers.Authorization, "Session");
|
||||||
|
assert.equal(calls[0].options.credentials, "include");
|
||||||
|
assert.equal(result.debugRequests[0].requestHeaders.Authorization, "Session");
|
||||||
|
assert.ok(!JSON.stringify(result).includes("secret-test"));
|
||||||
|
assert.equal(result.debugRequests[0].name, "requestInternalServiceRequest");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("SR details distinguish empty collections from errors and validate inputs", async () => {
|
||||||
|
for (const body of [{ items: [] }, { items: [{}] }, { items: [{ ServiceRequest_SRToInternalSR_Tgt: [] }] }]) {
|
||||||
|
const h = loadBackground({ fetch: async () => response(body) });
|
||||||
|
assert.deepEqual((await h.dispatch({ type, srId: "1" })).items, []);
|
||||||
|
}
|
||||||
|
for (const fetch of [async () => response({}, 403), async () => response({}), async () => response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: {} }] })]) {
|
||||||
|
const h = loadBackground({ fetch });
|
||||||
|
assert.equal((await h.dispatch({ type, srId: "1", token: "test" })).ok, false);
|
||||||
|
}
|
||||||
|
const h = loadBackground({ fetch: () => assert.fail("Invalid input must not fetch") });
|
||||||
|
assert.equal((await h.dispatch({ type, srId: "1 OR 1=1", token: "test" })).ok, false);
|
||||||
|
assert.equal((await h.dispatch({ type })).ok, false);
|
||||||
|
});
|
||||||
@@ -1,6 +1,112 @@
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const { loadApplication } = require("./support/application.cjs");
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
|
||||||
|
test("internal SR form validates Save, maps parent data, prevents duplicate posts and preserves failures", async () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.currentUser.getUserId = () => "current@example.invalid";
|
||||||
|
h.app.opportunitiesTableState.opportunitiesTableState.items = [
|
||||||
|
{ OptyNumber: "OTHER", Name: "Other opportunity", CustomerAccount: { PrimaryAddress: { CountryCode: "US" } } },
|
||||||
|
{ OptyNumber: "OP1", Name: "<Opportunity name>", OptyId: 456, CountryCode: "CA",
|
||||||
|
CustomerAccount: { PartyId: 789, Region_c_localizedValue: "LAD", CountryCode: "AR", PrimaryAddress: { CountryCode: "BR" } } }
|
||||||
|
];
|
||||||
|
h.app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{ SrId: "123", SrNumber: "SR123",
|
||||||
|
CustomerDeadline_c: "2026-05-14T00:00:00.000+0000", PrimaryPillarNew_c: "Tech_Cloud_Infrastructure", Title: "<Parent SR title>", OpportunityName_Id_c: { OptyNumber: "OP1" } }];
|
||||||
|
const calls = [];
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = async message => {
|
||||||
|
calls.push(message);
|
||||||
|
return { ok: true, items: message.type.endsWith("requestLookupTypePlatform") ? [{ code: "OCI", name: "Oracle Cloud" }]
|
||||||
|
: message.type.endsWith("requestInternalSrServices") ? [{ Id: "45", RecordName: "Review", Description_c: "Architecture review" }] : [] };
|
||||||
|
};
|
||||||
|
await h.app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
const badge = h.app.opportunityCrmServiceRequests.createBadge("OP1");
|
||||||
|
h.modal.append(badge);
|
||||||
|
badge.listeners.pointerenter();
|
||||||
|
await flush();
|
||||||
|
const add = h.walk(h.panel()).find(el => el.title === "Create Internal Service Request");
|
||||||
|
assert.ok(add);
|
||||||
|
assert.equal(add.parent.children[2].title, "Copy SR number");
|
||||||
|
add.listeners.click({ preventDefault() {}, stopPropagation() {} });
|
||||||
|
await flush();
|
||||||
|
const nodes = h.walk(h.modal);
|
||||||
|
const context = nodes.find(el => el.className === "opportunities-extension-action-form-context");
|
||||||
|
assert.deepEqual(context.children.map(el => el.textContent), ["SR Number", "SR123", "Opportunity", "<Opportunity name>", "Title", "<Parent SR title>"]);
|
||||||
|
assert.equal(nodes.find(el => el.name === "customerDeadline").value, "14/05/2026");
|
||||||
|
assert.equal(nodes.find(el => el.name === "customerDeadline").readOnly, true);
|
||||||
|
assert.equal(nodes.find(el => el.name === "customerDeadline").required, false);
|
||||||
|
for (const name of ["title", "internalDeadline", "platform", "description", "services"]) {
|
||||||
|
const input = nodes.find(el => el.name === name);
|
||||||
|
assert.equal(input.required, true, name);
|
||||||
|
assert.equal(input.attributes["aria-required"], "true", name);
|
||||||
|
assert.match(input.parent.children[0].textContent, /\(required\)$/);
|
||||||
|
}
|
||||||
|
for (const name of ["platform", "services"]) {
|
||||||
|
const input = nodes.find(el => el.id === `internal-sr-${name}-search`);
|
||||||
|
assert.equal(input.required, true);
|
||||||
|
assert.equal(input.attributes["aria-required"], "true");
|
||||||
|
}
|
||||||
|
assert.equal(nodes.find(el => el.name === "internalDeadline").type, "date");
|
||||||
|
assert.equal(nodes.find(el => el.name === "platform").children[1].value, "OCI");
|
||||||
|
assert.equal(nodes.find(el => el.name === "services").children[1].attributes["data-description"], "Architecture review");
|
||||||
|
assert.equal(calls.at(-1).parentServiceId, "123");
|
||||||
|
assert.equal(calls.at(-1).emailAddress, "current@example.invalid");
|
||||||
|
const form = nodes.find(el => el.tag === "form");
|
||||||
|
assert.deepEqual(h.walk(form).filter(el => el.tag === "button").map(el => el.textContent), ["Save", "Cancel"]);
|
||||||
|
const submit = () => form.listeners.submit({ preventDefault() {} });
|
||||||
|
const before = calls.length;
|
||||||
|
await submit();
|
||||||
|
assert.equal(calls.length, before);
|
||||||
|
assert.equal(nodes.find(el => el.name === "title").attributes["aria-invalid"], "true");
|
||||||
|
for (const [name, value] of Object.entries({ title: "New internal SR", internalDeadline: "2026-05-01", platform: "OCI", description: "Detailed request", services: "45" })) nodes.find(el => el.name === name).value = value;
|
||||||
|
const posts = [], toasts = [];
|
||||||
|
h.app.opportunitiesLinks.showCopyConfirmation = (message, isError = false) => toasts.push({ message, isError });
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => posts.push({ message, resolve }));
|
||||||
|
const saving = submit();
|
||||||
|
await submit();
|
||||||
|
assert.equal(posts.length, 1);
|
||||||
|
assert.equal(posts[0].message.type, "opportunitiesExtension.requestPostInternalServiceRequest");
|
||||||
|
assert.equal(posts[0].message.emailAddress, "current@example.invalid");
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(posts[0].message.payload)), {
|
||||||
|
Title: "New internal SR", ServiceNew_Id_c: "45",
|
||||||
|
ProblemDescription: "Detailed request", CustomerDeadline_c: "2026-05-14", TechPlatformLookUp_c: "OCI",
|
||||||
|
ServiceRequest_Id_SRToInternalSR: "123", SvcQuestionsCollection_c: [], InternalDeadline_c: "2026-05-01"
|
||||||
|
});
|
||||||
|
posts[0].resolve({ ok: false, httpStatus: 403, error: "Synthetic save failure" });
|
||||||
|
await saving;
|
||||||
|
assert.equal(nodes.find(el => el.name === "title").value, "New internal SR");
|
||||||
|
assert.deepEqual(toasts, [{ message: "Synthetic save failure", isError: true }]);
|
||||||
|
const retry = submit();
|
||||||
|
posts[1].resolve({ ok: true, result: { SrNumber: "ISR1" } });
|
||||||
|
await retry;
|
||||||
|
assert.deepEqual(toasts.at(-1), { message: "Internal Service Request created successfully.", isError: false });
|
||||||
|
assert.ok(!h.walk(h.modal).includes(form));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("internal SR uses Session without TokenRelay and reports unavailable iframe host", async () => {
|
||||||
|
const h = setup();
|
||||||
|
let renewals = 0;
|
||||||
|
const sent = [];
|
||||||
|
h.app.authSessionState.accessToken = "stale-token";
|
||||||
|
h.app.authTokenRelay.requestTokenRelay = async () => `fresh-${++renewals}`;
|
||||||
|
h.app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = async message => {
|
||||||
|
sent.push(message);
|
||||||
|
return { ok: false, httpStatus: 401, error: "Unauthorized" };
|
||||||
|
};
|
||||||
|
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{ SrId: "123", SrNumber: "SR123", OpportunityName_Id_c: { OptyNumber: "OP1" } }];
|
||||||
|
await h.app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
const badge = h.app.opportunityCrmServiceRequests.createBadge("OP1");
|
||||||
|
h.modal.append(badge);
|
||||||
|
badge.listeners.pointerenter();
|
||||||
|
await flush();
|
||||||
|
assert.equal(renewals, 0);
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.equal(sent[0].token, undefined);
|
||||||
|
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
|
||||||
|
assert.ok(h.walk(h.panel()).some(node => node.attributes.role === "alert"));
|
||||||
|
});
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const document = { activeElement: null, listeners: new Map() };
|
const document = { activeElement: null, listeners: new Map() };
|
||||||
@@ -104,3 +210,45 @@ test("SR popover pins on click, closes on Escape/outside/scroll/reset and remove
|
|||||||
assert.equal(panel(), undefined);
|
assert.equal(panel(), undefined);
|
||||||
assert.equal(document.listeners.size, 0);
|
assert.equal(document.listeners.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("SR tooltip loads nested details with copy/link and empty text, discarding stale responses", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const calls = [];
|
||||||
|
h.app.authSessionState.accessToken = "crm-token";
|
||||||
|
h.app.authTokenRelay.requestTokenRelay = async () => "fresh-crm-token";
|
||||||
|
h.app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => calls.push({ message, resolve }));
|
||||||
|
await h.app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
const badge = h.app.opportunityCrmServiceRequests.createBadge("OP1");
|
||||||
|
h.modal.append(badge);
|
||||||
|
badge.listeners.pointerenter();
|
||||||
|
await flush();
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(calls[0].message.token, undefined);
|
||||||
|
assert.equal(calls[0].message.srId, "NOT-THE-URL-ID");
|
||||||
|
assert.ok(h.walk(h.panel()).some(node => node.textContent === "Loading SR details..."));
|
||||||
|
calls[0].resolve({ ok: true, items: [{ SrId: 99, SrNumber: "SR &child", Title: "<child>", ServiceNew_c: "Queue",
|
||||||
|
LastUpdateDate: "2026-05-14T00:00:00.000+0000", OpenDate: "2026-04-01T23:30:00.000+0000" }] });
|
||||||
|
calls[1].resolve({ ok: true, items: [] });
|
||||||
|
await flush();
|
||||||
|
const nodes = h.walk(h.panel());
|
||||||
|
const link = nodes.find(node => node.tag === "a" && node.textContent === "SR &child");
|
||||||
|
assert.equal(link.parent.children[0].textContent, "Internal SR Number:");
|
||||||
|
assert.ok(link.href.endsWith("srNumber=SR%20%26child&srId=99"));
|
||||||
|
assert.equal(link.parent.children[2].title, "Copy SR number");
|
||||||
|
assert.ok(nodes.some(node => node.textContent.includes("Service Queue: Queue")));
|
||||||
|
assert.ok(nodes.some(node => node.textContent.includes("LastUpdateDate: 14/05/2026")));
|
||||||
|
assert.ok(nodes.some(node => node.textContent.includes("OpenDate: 01/04/2026")));
|
||||||
|
assert.ok(nodes.some(node => node.textContent === "No further details."));
|
||||||
|
h.app.crmSrPopover?.close();
|
||||||
|
h.app.opportunityCrmServiceRequests.reset();
|
||||||
|
badge.listeners.pointerenter();
|
||||||
|
const oldPanel = h.panel();
|
||||||
|
await flush();
|
||||||
|
h.app.opportunityCrmServiceRequests.reset();
|
||||||
|
calls[2].resolve({ ok: true, items: [{ SrNumber: "STALE" }] });
|
||||||
|
calls[3].resolve({ ok: false, error: "Forbidden" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.panel(), undefined);
|
||||||
|
assert.ok(!h.walk(oldPanel).some(node => node.textContent === "STALE"));
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const { loadApplication } = require("./support/application.cjs");
|
|||||||
const type = "opportunitiesExtension.requestCurrentUser";
|
const type = "opportunitiesExtension.requestCurrentUser";
|
||||||
|
|
||||||
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 });
|
||||||
@@ -20,12 +20,16 @@ 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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,7 +41,8 @@ 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/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,10 +78,12 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
|
|||||||
assert.equal(same, app.currentUser.ensure());
|
assert.equal(same, app.currentUser.ensure());
|
||||||
finish({ ok: true, user: { roles } });
|
finish({ ok: true, user: { roles } });
|
||||||
await first;
|
await first;
|
||||||
assert.deepEqual(calls, allowed ? [type, "tokenrelay", "crm", "opportunities"] : [type, "tokenrelay", "opportunities"]);
|
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]);
|
||||||
|
app.productsContentState.currentProductsRequestId = "products-cycle";
|
||||||
|
app.productActivities.begin("products-cycle", "synthetic");
|
||||||
const products = { items: [{ RevnId: "123" }] };
|
const products = { items: [{ RevnId: "123" }] };
|
||||||
app.productsContentState.opptyProductsByNumber.set("OP", products);
|
app.productsContentState.opptyProductsByNumber.set("OP", products);
|
||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.productActivities.complete("products-cycle");
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), allowed);
|
assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), allowed);
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
|
|||||||
48
tests/dashboardTotals.test.cjs
Normal file
48
tests/dashboardTotals.test.cjs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor() { this.children = []; this.attributes = {}; }
|
||||||
|
set textContent(value) { this.text = value; this.children = []; }
|
||||||
|
get textContent() { return this.text; }
|
||||||
|
append(...nodes) { this.children.push(...nodes); }
|
||||||
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
|
addEventListener() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Total Opportunities displays filtered/overall and updates with filters and Reference data", () => {
|
||||||
|
const dashboard = new Element(), table = new Element();
|
||||||
|
const app = loadApplication("content", {
|
||||||
|
document: {
|
||||||
|
createElement: () => new Element(),
|
||||||
|
getElementById: id => id === "opportunities-extension-stage-dashboard" ? dashboard
|
||||||
|
: id === "opportunities-extension-table" ? table : null
|
||||||
|
},
|
||||||
|
performance: { now: () => 0 }, requestAnimationFrame: tick => tick(480)
|
||||||
|
});
|
||||||
|
// Use real table/dashboard coordination without constructing unrelated row controls.
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState.status = "loading";
|
||||||
|
const items = Array.from({ length: 26 }, () => ({ ForecastGroup_c: "FORECAST" }));
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState.items = items;
|
||||||
|
let filtered = items.slice(0, 11);
|
||||||
|
app.opportunitiesFilters.getSortedOpportunities = () => filtered;
|
||||||
|
const value = () => dashboard.children[0].children[1];
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
assert.equal(value().textContent, "11/26");
|
||||||
|
assert.equal(value().attributes["aria-label"], "11 filtered opportunities out of 26 total");
|
||||||
|
filtered = [];
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
assert.equal(value().textContent, "0/26");
|
||||||
|
filtered = items;
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
assert.equal(value().textContent, "26/26");
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState.items = items.slice(0, 3);
|
||||||
|
filtered = items.slice(0, 2);
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
assert.equal(value().textContent, "2/3");
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState.items = [];
|
||||||
|
filtered = [];
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
assert.equal(value().textContent, "0/0");
|
||||||
|
});
|
||||||
@@ -20,23 +20,56 @@ function fixture() {
|
|||||||
return { ...dom, app, add, nodes, tab, ids, select };
|
return { ...dom, app, add, nodes, tab, ids, select };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
|
test("catalog and identity secrets are revealed exactly by the eye button and hidden again", () => {
|
||||||
|
const h = fixture();
|
||||||
|
const body = JSON.stringify({ result: "catalog-exact-secret", detail: "Preserved" });
|
||||||
|
h.app.debugContentLog.appendBackgroundRequestLog([{
|
||||||
|
name: "requestSrTokenCatalog", method: "POST", startedAt: new Date().toISOString(), status: "success",
|
||||||
|
responseBody: body, requestHeaders: { "vb-proxy-header-x-oracle-ectoken": "header-exact-secret", "x-appbuilder-client-id": "client-exact-secret" }
|
||||||
|
}, {
|
||||||
|
name: "requestCurrentUser", method: "GET", startedAt: new Date().toISOString(), status: "success",
|
||||||
|
responseBody: JSON.stringify({ user: { roles: ["SIMPLE_USER"] }, token: "identity-exact-secret" })
|
||||||
|
}]);
|
||||||
|
const text = node => node.text + node.children.map(text).join("");
|
||||||
|
const toggle = () => h.nodes().find(node => node.getAttribute("data-debug-focus") === "visibility").listeners.click();
|
||||||
|
assert.doesNotMatch(text(h.panel), /exact-secret/);
|
||||||
|
toggle();
|
||||||
|
for (const secret of ["catalog-exact-secret", "header-exact-secret", "client-exact-secret", "identity-exact-secret"]) assert.ok(text(h.panel).includes(secret));
|
||||||
|
toggle();
|
||||||
|
assert.doesNotMatch(text(h.panel), /exact-secret/);
|
||||||
|
assert.equal(h.app.debugState.requestLog[0].responseBody, body, "display masking never overwrites originals");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("request summaries show duration after status, including zero, failure and pending", () => {
|
||||||
|
const h = fixture();
|
||||||
|
h.add("duration", "requestOpportunities", 1);
|
||||||
|
const entry = h.app.debugState.requestLog.at(-1);
|
||||||
|
for (const [durationMs, status, expected] of [[0, "success", "0 ms"], [325, "success", "325 ms"], [1540, "failed", "1.54 s"], [null, "pending", "In progress"], [null, "failed", "N/D"]]) {
|
||||||
|
Object.assign(entry, { durationMs, status });
|
||||||
|
h.app.debugContentView.renderDebugPanel();
|
||||||
|
const meta = h.nodes().find(node => node.className === "opportunities-extension-debug-summary-meta");
|
||||||
|
assert.equal(meta.children.at(-1).textContent, expected);
|
||||||
|
assert.equal(meta.children.at(-2).className, "opportunities-extension-debug-status");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -45,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)");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,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",
|
||||||
@@ -256,7 +291,7 @@ 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"]
|
||||||
.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"));
|
||||||
|
|||||||
14
tests/fixtures/extension.js
vendored
14
tests/fixtures/extension.js
vendored
@@ -1,13 +1,15 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const listeners = [];
|
const listeners = [];
|
||||||
const storage = new Map();
|
const storage = new Map();
|
||||||
|
const internalSrPreview = new URLSearchParams(location.search).has("internal-sr");
|
||||||
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 ? 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]
|
||||||
@@ -28,6 +30,11 @@
|
|||||||
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 === "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 +71,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}`);
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -59,6 +59,7 @@ test("ten columns retain their links and put product/customer controls below the
|
|||||||
assert.ok(name.children[0].href.endsWith("TEST-1"));
|
assert.ok(name.children[0].href.endsWith("TEST-1"));
|
||||||
assert.equal(name.children[1].children[0].className, "opportunities-extension-products-count");
|
assert.equal(name.children[1].children[0].className, "opportunities-extension-products-count");
|
||||||
assert.equal(name.children[1].children[0].textContent, "1");
|
assert.equal(name.children[1].children[0].textContent, "1");
|
||||||
|
assert.equal(name.children[1].children[0].title, "1 product");
|
||||||
assert.equal(customer.children[0].textContent, "Synthetic customer");
|
assert.equal(customer.children[0].textContent, "Synthetic customer");
|
||||||
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
|
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
|
||||||
assert.equal(customer.children[1].children[0].title, "View customer consumption");
|
assert.equal(customer.children[1].children[0].title, "View customer consumption");
|
||||||
@@ -69,10 +70,11 @@ test("ten columns retain their links and put product/customer controls below the
|
|||||||
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
|
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop filter widths fit Reference content and allocate free space to Customer and Owner", () => {
|
test("all four filter fields share available width dynamically with responsive wrapping", () => {
|
||||||
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");
|
||||||
assert.match(css, /grid-template-columns: max-content minmax\(110px, 1fr\) minmax\(110px, 1fr\) minmax\(145px, 180px\) max-content max-content/);
|
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) max-content max-content;/);
|
||||||
assert.match(css, /@media \(min-width: 1151px\)[\s\S]*?\.opportunities-extension-field select\s*\{\s*width: max-content;/);
|
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
|
||||||
|
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
|
||||||
assert.match(css, /@media \(max-width: 1150px\)/);
|
assert.match(css, /@media \(max-width: 1150px\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,8 +83,8 @@ test("ramp months preserve zero and flag missing values beside the product count
|
|||||||
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
|
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
|
||||||
assert.equal(badge(), undefined);
|
assert.equal(badge(), undefined);
|
||||||
for (const [values, expected] of [
|
for (const [values, expected] of [
|
||||||
[[], null], [[null], "RM N/D"], [[null, undefined, "", " "], "RM N/D"], [[6], "RM 6"],
|
[[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
|
||||||
[[0], "RM 0"], [[0, "3", null, " 6 ", 3], "RM 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 }))
|
||||||
@@ -92,10 +94,13 @@ 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.match(badge().title, /^Ramp Months:/);
|
assert.equal(badge().title, `Ramp Months: ${expected.split(" | ").join(", ")}`);
|
||||||
|
assert.equal(badge().attributes["aria-label"], badge().title);
|
||||||
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
|
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
|
||||||
}
|
}
|
||||||
assert.equal(findClass(h.cell("name"), "opportunities-extension-products-count").textContent, String(values.length));
|
const count = findClass(h.cell("name"), "opportunities-extension-products-count");
|
||||||
|
assert.equal(count.textContent, String(values.length));
|
||||||
|
assert.equal(count.title, `${values.length} product${values.length === 1 ? "" : "s"}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,6 +122,33 @@ test("products remain expandable with AI summary and a ten-column detail row aft
|
|||||||
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
|
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("product labels support multiple digits without changing their tooltip", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: Array.from({ length: 12 }, () => ({})) });
|
||||||
|
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
const count = findClass(h.cell("name"), "opportunities-extension-products-count");
|
||||||
|
assert.equal(count.textContent, "12");
|
||||||
|
assert.equal(count.title, "12 products");
|
||||||
|
assert.equal(count.attributes["aria-label"], "12 products for opportunity TEST-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opportunity badges share height, typography and alignment including the Action plus button", () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
|
const shared = css.match(/\.opportunities-extension-cell-actions > \.opportunities-extension-products-count,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-products-ramp,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-crm-sr-badge\s*\{([^}]+)\}/);
|
||||||
|
assert.ok(shared, "P, R, SR and Act share one scoped badge rule");
|
||||||
|
for (const declaration of ["display: inline-flex;", "align-items: center;", "justify-content: flex-start;",
|
||||||
|
"box-sizing: border-box;", "height: 20px;", "min-height: 20px;", "padding: 0 6px;", "margin: 0;",
|
||||||
|
"border-radius: 8px;", "font-family: inherit;", "font-size: 10px;", "font-weight: 600;", "line-height: 16px;"]) {
|
||||||
|
assert.ok(shared[1].includes(declaration), declaration);
|
||||||
|
}
|
||||||
|
const plus = css.match(/\.opportunities-extension-add-action\s*\{([^}]+)\}/)[1];
|
||||||
|
assert.match(plus, /height: 16px;/);
|
||||||
|
assert.match(plus, /align-items: center;/);
|
||||||
|
assert.match(plus, /font: inherit;/);
|
||||||
|
const actions = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/workload-actions.js"), "utf8");
|
||||||
|
assert.match(actions, /badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge"/);
|
||||||
|
});
|
||||||
|
|
||||||
test("CRM SR badge follows Ramp Months and remains visible while product data is pending", async () => {
|
test("CRM SR badge follows Ramp Months and remains visible while product data is pending", async () => {
|
||||||
const h = setup();
|
const h = setup();
|
||||||
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{
|
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{
|
||||||
@@ -124,11 +156,11 @@ test("CRM SR badge follows Ramp Months and remains visible while product data is
|
|||||||
}];
|
}];
|
||||||
await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion);
|
await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion);
|
||||||
h.app.opportunitiesTable.renderOpportunitiesTable();
|
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "\u2705 SR");
|
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "0h");
|
||||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
|
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
|
||||||
h.app.opportunitiesTable.renderOpportunitiesTable();
|
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
const actions = h.cell("name").children[0].children[1];
|
const actions = h.cell("name").children[0].children[1];
|
||||||
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "RM 3", "\u2705 SR", "Act ?"]);
|
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "3", "0h", "?"]);
|
||||||
findClass(h.cell("name"), "opportunities-extension-products-count").click();
|
findClass(h.cell("name"), "opportunities-extension-products-count").click();
|
||||||
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
|
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
|
||||||
});
|
});
|
||||||
@@ -210,7 +242,9 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
|||||||
assert.match(css, /flex: 0 0 24px/);
|
assert.match(css, /flex: 0 0 24px/);
|
||||||
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-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/);
|
||||||
@@ -220,6 +254,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"].map(read).join("\n");
|
||||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||||
});
|
});
|
||||||
|
|||||||
201
tests/productActivities.test.cjs
Normal file
201
tests/productActivities.test.cjs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
function deferred() {
|
||||||
|
let resolve, reject;
|
||||||
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const first of ["SR", "Actions"]) {
|
||||||
|
test(`Last update waits for every background call when ${first} finishes first`, async () => {
|
||||||
|
const h = setup();
|
||||||
|
const sr = deferred(), actions = deferred();
|
||||||
|
let renders = 0;
|
||||||
|
h.app.productsContentJobs.renderProductsProgress = () => { renders += 1; };
|
||||||
|
h.app.productsContentState.productsLastCachedAt = 100;
|
||||||
|
h.app.opportunityCrmServiceRequests.load = () => sr.promise;
|
||||||
|
h.app.opportunityWorkloadActions.loadProducts = () => actions.promise;
|
||||||
|
const id = h.start();
|
||||||
|
h.progress(id, { state: "running", optyNumber: "A", items: [{ RevnId: "1" }], cachedAt: 200 });
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
h.progress(id, { state: "complete" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
(first === "SR" ? sr : actions).resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
(first === "SR" ? actions : sr).resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
|
||||||
|
assert.equal(renders, 1, "publish the completed cycle once");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Last update ignores superseded cycles and settles failures without changing the cache timestamp", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const old = deferred(), current = deferred();
|
||||||
|
h.app.productsContentState.productsLastCachedAt = 100;
|
||||||
|
h.app.opportunityCrmServiceRequests.load = () => old.promise;
|
||||||
|
const first = h.start();
|
||||||
|
h.progress(first, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
|
||||||
|
h.app.opportunityCrmServiceRequests.load = () => current.promise;
|
||||||
|
const second = h.start();
|
||||||
|
h.progress(second, { state: "complete", optyNumber: "A", items: [], cachedAt: 300 });
|
||||||
|
old.resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
current.reject(new Error("Synthetic failure"));
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
assert.equal(h.calls.includes("empty products"), false, "SR failure blocks Actions");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Last update waits for cache restoration and ignores a superseded Reference", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const restore = deferred();
|
||||||
|
h.app.productsContentState.productsLastCachedAt = 100;
|
||||||
|
h.app.opportunityCrmServiceRequests.load = () => restore.promise;
|
||||||
|
const id = h.start();
|
||||||
|
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
h.app.opportunitiesQueryState.periodRequestVersion += 1;
|
||||||
|
restore.resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
const next = h.start();
|
||||||
|
h.progress(next, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Last update completes for SALES_REP without activity requests", async () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.opportunityCrmServiceRequests.load = () => undefined;
|
||||||
|
h.app.opportunityWorkloadActions.loadProducts = () => undefined;
|
||||||
|
const id = h.start();
|
||||||
|
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
let listener;
|
||||||
|
const app = loadApplication("content", {
|
||||||
|
document: { getElementById: () => null }, window: { requestAnimationFrame() {} },
|
||||||
|
chrome: { runtime: { onMessage: { addListener(fn) { listener = fn; } } } }
|
||||||
|
});
|
||||||
|
const calls = [];
|
||||||
|
app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR");
|
||||||
|
app.opportunityWorkloadActions.reset = () => calls.push("reset Act");
|
||||||
|
app.opportunityCrmServiceRequests.load = async (token, version, options) => { if (!options?.cacheOnly) calls.push(`SR ${token}`); };
|
||||||
|
app.opportunityWorkloadActions.loadProducts = (products, refresh, options) => { if (!options?.cacheOnly) calls.push(products.items[0]?.RevnId || "empty products"); };
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true });
|
||||||
|
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
||||||
|
function start(token = "token", numbers = ["A"]) {
|
||||||
|
app.productsContentJobs.requestOpptyProductsInBackground(numbers.map(OptyNumber => ({ OptyNumber })), token);
|
||||||
|
return app.productsContentState.currentProductsRequestId;
|
||||||
|
}
|
||||||
|
function progress(requestId, data) {
|
||||||
|
listener({ type: "opportunitiesExtension.opptyProductsProgress", requestId, ...data });
|
||||||
|
}
|
||||||
|
return { app, calls, start, progress };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("follow-up calls wait for completion, deduplicate terminal messages and refresh on every new cycle", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const first = h.start();
|
||||||
|
h.progress(first, { state: "running", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: false });
|
||||||
|
assert.deepEqual(h.calls, []);
|
||||||
|
h.progress(first, { state: "complete" });
|
||||||
|
h.progress(first, { state: "complete" });
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(h.calls, ["SR token", "123"]);
|
||||||
|
const second = h.start("new-token");
|
||||||
|
h.progress(first, { state: "complete", optyNumber: "B", items: [{ RevnId: "old" }] });
|
||||||
|
h.progress(second, { state: "running", optyNumber: "A", items: [{ RevnId: "123" }] });
|
||||||
|
assert.equal(h.calls.filter(value => value === "123").length, 1);
|
||||||
|
h.progress(second, { state: "complete" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.filter(value => value === "123").length, 2);
|
||||||
|
assert.ok(h.calls.includes("SR new-token"));
|
||||||
|
assert.equal(h.calls.includes("old"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial failures use successful products only; closed, failed and empty cycles have no follow-up", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const first = h.start();
|
||||||
|
h.progress(first, { optyNumber: "A", items: [{ RevnId: "123" }] });
|
||||||
|
h.progress(first, { optyNumber: "B", items: [{ RevnId: "failed" }], error: "Synthetic failure" });
|
||||||
|
h.progress(first, { state: "complete-with-errors" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.includes("failed"), false);
|
||||||
|
assert.equal(h.calls.filter(value => value === "SR token").length, 1);
|
||||||
|
const second = h.start();
|
||||||
|
h.progress(second, { optyNumber: "A", error: "Synthetic failure" });
|
||||||
|
h.progress(second, { state: "complete-with-errors" });
|
||||||
|
const third = h.start();
|
||||||
|
h.progress(third, { state: "error" });
|
||||||
|
const fourth = h.start();
|
||||||
|
h.app.productsContentState.currentProductsRequestId = "";
|
||||||
|
h.progress(fourth, { state: "complete", optyNumber: "A", items: [{ RevnId: "closed" }] });
|
||||||
|
h.app.productsContentJobs.requestOpptyProductsInBackground([], "empty");
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.filter(value => value.startsWith("SR ")).length, 1);
|
||||||
|
assert.equal(h.calls.includes("closed"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cached and mixed product cycles both allow activity cache misses to be fetched", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const cached = h.start();
|
||||||
|
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||||
|
h.progress(cached, { state: "complete" });
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(h.calls, ["SR token", "cached"]);
|
||||||
|
h.calls.length = 0;
|
||||||
|
const mixed = h.start("token", ["A", "B"]);
|
||||||
|
h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||||
|
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
|
||||||
|
h.progress(mixed, { state: "complete" });
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(h.calls, ["SR token", "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 () => {
|
||||||
|
const modal = { hidden: true, querySelector: () => ({ focus() {} }) };
|
||||||
|
const app = loadApplication("content", {
|
||||||
|
window: {}, document: {
|
||||||
|
getElementById: id => id === "opportunities-extension-modal" ? modal : null,
|
||||||
|
documentElement: { classList: { add() {} } }, addEventListener() {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.contentStyles.ensureExtensionStyles = () => {};
|
||||||
|
app.currentUser.ensure = async () => true;
|
||||||
|
app.authContentSession.requestTokenRelayOnce = async () => "synthetic";
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable = () => {};
|
||||||
|
const calls = [];
|
||||||
|
app.productsContentJobs.requestOpptyProductsInBackground = () => calls.push("products");
|
||||||
|
app.opportunityCrmServiceRequests.load = () => calls.push("SR");
|
||||||
|
app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR");
|
||||||
|
app.opportunityWorkloadActions.loadProducts = () => calls.push("Actions");
|
||||||
|
app.opportunityWorkloadActions.reset = () => calls.push("reset Actions");
|
||||||
|
app.contentModal.openOpportunitiesModal();
|
||||||
|
await flush();
|
||||||
|
assert.equal(modal.hidden, false);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
106
tests/refreshAll.test.cjs
Normal file
106
tests/refreshAll.test.cjs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: invalidation detaches old product flights and prevents stale cache restoration`, async () => {
|
||||||
|
const pending = [];
|
||||||
|
const storage = { preferences: { keep: true } };
|
||||||
|
const app = loadBackground({ family, storage, fetch: () => new Promise(resolve => pending.push(resolve)) });
|
||||||
|
const message = { type: "opportunitiesExtension.requestOpptyProducts", accessToken: "synthetic", optyNumbers: ["A"], cacheMaxAgeMs: 86400000 };
|
||||||
|
const old = app.dispatch({ ...message, requestId: "old" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(pending.length, 1);
|
||||||
|
const clear = await app.dispatch({ type: "opportunitiesExtension.invalidateProductsCache" });
|
||||||
|
assert.equal(clear.ok, true);
|
||||||
|
const fresh = app.dispatch({ ...message, requestId: "fresh" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(pending.length, 2);
|
||||||
|
pending[1](response({ items: [{ RevnId: "new" }], count: 1, hasMore: false }));
|
||||||
|
await fresh;
|
||||||
|
pending[0](response({ items: [{ RevnId: "old" }], count: 1, hasMore: false }));
|
||||||
|
await old;
|
||||||
|
await app.dispatch({ ...message, requestId: "cached" });
|
||||||
|
assert.equal(pending.length, 2);
|
||||||
|
assert.equal(storage["opportunitiesExtension.opptyProductsCache.v1"].A.items[0].RevnId, "new");
|
||||||
|
assert.deepEqual(storage.preferences, { keep: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("activity invalidation preserves unrelated storage and rejects late entries from old product timestamps", async () => {
|
||||||
|
const values = { preferences: "keep" };
|
||||||
|
const app = loadApplication("content", { browser: { storage: { local: {
|
||||||
|
get: async key => key === null ? { ...values } : { [key]: values[key] },
|
||||||
|
set: async data => Object.assign(values, data),
|
||||||
|
remove: async keys => keys.forEach(key => delete values[key])
|
||||||
|
} } } });
|
||||||
|
app.authSessionState.principal = "user@example.invalid";
|
||||||
|
const stamp = Date.now() - 1000;
|
||||||
|
await app.activityCache.write("sr", "all", stamp, []);
|
||||||
|
const oldKey = Object.keys(values).find(key => key.includes("activities.v1:"));
|
||||||
|
const oldEntry = values[oldKey];
|
||||||
|
await app.activityCache.invalidate();
|
||||||
|
assert.equal(values[oldKey], undefined);
|
||||||
|
assert.equal(values.preferences, "keep");
|
||||||
|
values[oldKey] = oldEntry;
|
||||||
|
assert.equal(await app.activityCache.read("sr", "all", stamp), null);
|
||||||
|
delete values[oldKey];
|
||||||
|
await app.activityCache.write("sr", "all", stamp, []);
|
||||||
|
assert.equal(values[oldKey], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function content() {
|
||||||
|
const app = loadApplication("content", { document: { createElement: () => ({ children: [], append(node) { this.children.push(node); }, listeners: {}, attributes: {}, addEventListener(key, fn) { this.listeners[key] = fn; }, setAttribute(key, value) { this.attributes[key] = value; } }) } });
|
||||||
|
app.contentIcons.createRefreshIcon = () => ({ tag: "svg" });
|
||||||
|
for (const [object, method] of [
|
||||||
|
["opportunityCrmServiceRequests", "reset"], ["opportunityWorkloadActions", "reset"],
|
||||||
|
["consumptionPlansView", "closeAccountPlansModal"], ["installBaseView", "closeInstallBaseModal"],
|
||||||
|
["serviceRequestsView", "closeServiceRequestsModal"], ["productsContentJobs", "renderProductsProgress"],
|
||||||
|
["opportunitiesTable", "setOpportunitiesTableState"]
|
||||||
|
]) app[object][method] = () => {};
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
test("Refresh all deduplicates clicks, clears memory before reload and preserves identity/preferences", async () => {
|
||||||
|
const app = content();
|
||||||
|
const calls = [];
|
||||||
|
let finish;
|
||||||
|
app.activityCache.invalidate = () => { calls.push("activities"); return new Promise(resolve => { finish = resolve; }); };
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async message => { calls.push(message.type); return { ok: true }; };
|
||||||
|
app.opportunitiesController.requestOpportunitiesForPeriod = async period => calls.push(period);
|
||||||
|
app.authSessionState.accessToken = "synthetic";
|
||||||
|
app.preferencesState.opptyProductsCacheHours = 48;
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", {});
|
||||||
|
app.productsContentState.productsLastCachedAt = 100;
|
||||||
|
app.consumptionTrendState.consumptionTrendCache.set("A", {});
|
||||||
|
const first = app.refreshAll.refresh("Next Quarter");
|
||||||
|
assert.equal(app.refreshAll.refresh("Next Quarter"), first);
|
||||||
|
assert.equal(app.productsContentState.opptyProductsByNumber.size, 0);
|
||||||
|
assert.equal(app.productsContentState.productsLastCachedAt, 100, "keep previous update visible while refreshing");
|
||||||
|
assert.equal(app.consumptionTrendState.consumptionTrendCache.size, 0);
|
||||||
|
assert.deepEqual(calls, ["activities"]);
|
||||||
|
finish();
|
||||||
|
await first;
|
||||||
|
assert.deepEqual(calls, ["activities", "opportunitiesExtension.invalidateProductsCache", "Next Quarter"]);
|
||||||
|
assert.equal(app.authSessionState.accessToken, "synthetic");
|
||||||
|
assert.equal(app.preferencesState.opptyProductsCacheHours, 48);
|
||||||
|
});
|
||||||
|
test("Refresh all reports failures, does not reload on failed invalidation, and re-enables the button", async () => {
|
||||||
|
const app = content();
|
||||||
|
app.activityCache.invalidate = async () => { throw new Error("Synthetic storage failure"); };
|
||||||
|
let notified = "";
|
||||||
|
app.opportunitiesLinks.showCopyConfirmation = message => { notified = message; };
|
||||||
|
app.opportunitiesController.requestOpportunitiesForPeriod = () => assert.fail("must not reload");
|
||||||
|
const button = app.refreshAll.createButton(() => "Current Quarter");
|
||||||
|
const request = button.listeners.click();
|
||||||
|
assert.equal(button.disabled, true);
|
||||||
|
await request;
|
||||||
|
assert.equal(button.disabled, false);
|
||||||
|
assert.equal(button.title, "Refresh all");
|
||||||
|
assert.equal(button.attributes["aria-label"], "Refresh all");
|
||||||
|
assert.equal(button.children.length, 1);
|
||||||
|
assert.equal(button.children[0].tag, "svg");
|
||||||
|
assert.equal(button.attributes["aria-busy"], "false");
|
||||||
|
assert.equal(notified, "Synthetic storage failure");
|
||||||
|
});
|
||||||
391
tests/srCreate.test.cjs
Normal file
391
tests/srCreate.test.cjs
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
function assertDebugVisibility(result, secret) {
|
||||||
|
const app = loadApplication("content");
|
||||||
|
const entries = result.debugRequests || [];
|
||||||
|
const render = () => entries.map(entry => app.debugContentLog.formatHeaderBlock(entry.requestHeaders)
|
||||||
|
+ app.debugContentLog.formatDebugBody(entry.responseBody, entry.name)).join("\n");
|
||||||
|
assert.ok(!render().includes(secret));
|
||||||
|
app.debugState.debugSecretsVisible = true;
|
||||||
|
assert.equal(render().includes(secret), JSON.stringify(entries).includes(secret));
|
||||||
|
}
|
||||||
|
const type = "opportunitiesExtension.requestPostServiceRequest";
|
||||||
|
const emailAddress = "signed.in+sr@example.invalid";
|
||||||
|
const internalType = "opportunitiesExtension.requestPostInternalServiceRequest";
|
||||||
|
const internalInput = () => ({ Title: "Internal request title", ServiceNew_Id_c: "404", ProblemDescription: "Form description",
|
||||||
|
CustomerDeadline_c: "2026-11-29", TechPlatformLookUp_c: "DATABASE", InternalDeadline_c: "2027-01-31",
|
||||||
|
ServiceRequest_Id_SRToInternalSR: "505" });
|
||||||
|
const parentInfo = () => ({ SrId: 505, SrNumber: "SR505", AccountPartyId: 100000693906507, OpportunityName_c: "A8YPN8",
|
||||||
|
OpportunityName_Id_c: 302959906052470, RegionText_c: "LAD", SvcHealth_c: "SVC_HEALTH_YELLOW", Language_c: "PORTUGUESE",
|
||||||
|
CountryText_c: "BR", PrimaryPillarNew_c: "Tech_Cloud_Platform", SecondaryPillarNew_c: "Tech_Cloud_Infrastructure", ServiceNew_Id_c: 300010753841648 });
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: internal SR reads parent SRInfo, resolves user and posts form values with Session and observed client ID`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (options.method === "GET" && url.includes("/serviceRequests?")) return withClientId({ items: [parentInfo()] }, 200, "internal-client");
|
||||||
|
if (url.includes("/resourceUsers?")) return response({ resourcePartyId: 303 });
|
||||||
|
assert.ok(!url.includes("Catalogue_c"), "Internal SR must not use catalog signing");
|
||||||
|
return response({ SrNumber: "ISR1" }, 201);
|
||||||
|
} });
|
||||||
|
const input = { ...internalInput(), AccountPartyId: 1, OpportunityName_c: "STALE", OpportunityName_Id_c: 2, RegionText_c: "APAC",
|
||||||
|
SvcHealth_c: "SVC_HEALTH_GREEN", Language_c: "ENGLISH", CountryText_c: "US", PrimaryPillarNew_c: "STALE", SecondaryPillarNew_c: null, ReportedByPartyId: 999 };
|
||||||
|
const result = await h.dispatch({ type: internalType, emailAddress, payload: input });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
const info = new URL(calls[0].url);
|
||||||
|
assert.match(info.pathname, /SeaaS_SR_management.*\/oalsetCRMRestAPI\/.*resources\/latest\/serviceRequests$/);
|
||||||
|
assert.equal(info.searchParams.get("q"), "SrId=505");
|
||||||
|
assert.equal(info.searchParams.get("onlyData"), "true");
|
||||||
|
assert.equal(info.searchParams.get("orderBy"), "LastUpdateDate:desc");
|
||||||
|
assert.equal(info.searchParams.get("fields"), "PrimaryPillarNew_c,SecondaryPillarNew_c,SvcHealth_c,SrNumber,AccountPartyId,CountryText_c,Language_c,OpportunityName_Id_c,OpportunityName_c,RegionText_c,ServiceNew_Id_c");
|
||||||
|
const post = calls[2];
|
||||||
|
assert.equal(new URL(calls[1].url).searchParams.get("emailAddress"), emailAddress);
|
||||||
|
assert.match(post.url, /SeaaS_SR_management.*oalsetCRMRestAPICloudAccount.*resources\/latest\/serviceRequests$/);
|
||||||
|
assert.equal(post.options.method, "POST");
|
||||||
|
const { SrId, SrNumber, ...parent } = parentInfo();
|
||||||
|
assert.deepEqual(JSON.parse(post.options.body), { ...parent, ...internalInput(),
|
||||||
|
ServiceRequest_Id_SRToInternalSR: "505", ServiceNew_Id_c: 404,
|
||||||
|
InternalDeadline_c: "2027-01-31T00:00:00.000Z", ReportedByPartyId: 303, SvcQuestionsCollection_c: [] });
|
||||||
|
assert.ok(calls.every(call => call.options.headers.Authorization === "Session" && call.options.credentials === "include"));
|
||||||
|
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
assert.deepEqual({ ...post.options.headers }, { Accept: "application/json", "Content-Type": "application/json",
|
||||||
|
Authorization: "Session", Origin: origin, "x-appbuilder-client-id": "internal-client" });
|
||||||
|
assertDebugVisibility(result, "internal-client");
|
||||||
|
assert.equal(calls[0].options.headers.Origin, undefined);
|
||||||
|
assert.equal(result.debugRequests[2].requestHeaders.Origin, origin);
|
||||||
|
assert.equal(h.headerRules.length, 1);
|
||||||
|
const { removeRuleIds, addRules } = h.headerRules[0];
|
||||||
|
assert.deepEqual(removeRuleIds, [7203]);
|
||||||
|
assert.equal(addRules.length, 1);
|
||||||
|
const rule = addRules[0];
|
||||||
|
assert.deepEqual(rule.action, { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: origin }] });
|
||||||
|
assert.deepEqual(rule.condition.initiatorDomains, ["synthetic-extension"]);
|
||||||
|
assert.deepEqual(rule.condition.requestMethods, ["post"]);
|
||||||
|
assert.deepEqual(rule.condition.resourceTypes, ["xmlhttprequest"]);
|
||||||
|
const pattern = new RegExp(rule.condition.regexFilter);
|
||||||
|
assert.ok(pattern.test(post.url));
|
||||||
|
assert.ok(!pattern.test(calls[0].url));
|
||||||
|
assert.ok(!pattern.test(post.url + "/other"));
|
||||||
|
assert.ok(!pattern.test(post.url + "?other=1"));
|
||||||
|
assert.deepEqual(result.debugRequests.map(entry => entry.name), ["requestSRInfo", "requestResourceUser", "requestPostInternalServiceRequest"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("internal SR queries the current parent on every Save and reuses captured client ID after worker restart", async () => {
|
||||||
|
const sessionStorage = {};
|
||||||
|
const queries = [], posts = [];
|
||||||
|
const fetch = async (url, options) => {
|
||||||
|
if (url.includes("/serviceRequests?")) {
|
||||||
|
const id = Number(new URL(url).searchParams.get("q").slice("SrId=".length));
|
||||||
|
queries.push(id);
|
||||||
|
const parent = { ...parentInfo(), SrId: id, SecondaryPillarNew_c: null, ServiceNew_Id_c: id + 1000 };
|
||||||
|
if (id === 606) delete parent.ServiceNew_Id_c;
|
||||||
|
return id === 505 ? withClientId({ items: [parent] }, 200, "saved-internal-client") : response({ items: [parent] });
|
||||||
|
}
|
||||||
|
if (url.includes("/resourceUsers?")) return response({ resourcePartyId: 303 });
|
||||||
|
posts.push(options);
|
||||||
|
return response({ SrNumber: "ISR1" }, 201);
|
||||||
|
};
|
||||||
|
const h = loadBackground({ fetch, sessionStorage });
|
||||||
|
assert.equal((await h.dispatch({ type: internalType, emailAddress, payload: internalInput() })).ok, true);
|
||||||
|
const restarted = loadBackground({ fetch, sessionStorage });
|
||||||
|
assert.equal((await restarted.dispatch({ type: internalType, emailAddress,
|
||||||
|
payload: { ...internalInput(), ServiceRequest_Id_SRToInternalSR: "606" } })).ok, true);
|
||||||
|
assert.deepEqual(queries, [505, 606]);
|
||||||
|
assert.equal(posts[1].headers["x-appbuilder-client-id"], "saved-internal-client");
|
||||||
|
assert.equal(JSON.parse(posts[1].body).ServiceRequest_Id_SRToInternalSR, "606", "links to parent SrId, independently of the parent's service ID");
|
||||||
|
assert.equal(JSON.parse(posts[1].body).SecondaryPillarNew_c, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("internal SR save validates form fields and parent ID before requesting SRInfo", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const h = loadBackground({ fetch: async () => { calls++; return response({}, 403); } });
|
||||||
|
for (const change of [{ Title: "" }, { ProblemDescription: "" }, { TechPlatformLookUp_c: "" }, { InternalDeadline_c: "2026-02-30" },
|
||||||
|
{ CustomerDeadline_c: "2026-02-30" }, { ServiceNew_Id_c: "bad" }, { ServiceRequest_Id_SRToInternalSR: "bad" }]) {
|
||||||
|
assert.equal((await h.dispatch({ type: internalType, emailAddress, payload: { ...internalInput(), ...change } })).ok, false);
|
||||||
|
}
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal((await h.dispatch({ type: internalType, emailAddress, payload: internalInput() })).ok, false);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("internal SR stops after missing or rejected parent data, failed user lookup or POST and never retries creation", async () => {
|
||||||
|
for (const mode of ["info-401", "info-500", "empty", "multiple", "wrong-parent", "missing-country", "user", "post-403", "post-500", "network"]) {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (url.includes("/serviceRequests?")) {
|
||||||
|
const parent = parentInfo();
|
||||||
|
if (mode === "wrong-parent") parent.SrId = 999;
|
||||||
|
if (mode === "missing-country") delete parent.CountryText_c;
|
||||||
|
return response({ items: mode === "empty" ? [] : mode === "multiple" ? [parent, parent] : [parent] }, mode === "info-401" ? 401 : mode === "info-500" ? 500 : 200);
|
||||||
|
}
|
||||||
|
if (url.includes("/resourceUsers?")) return response({ resourcePartyId: 303 }, mode === "user" ? 403 : 200);
|
||||||
|
if (mode === "network") throw new Error("Synthetic network failure");
|
||||||
|
return response({}, mode === "post-403" ? 403 : 500);
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type: internalType, emailAddress, payload: internalInput() });
|
||||||
|
assert.equal(result.ok, false, mode);
|
||||||
|
const hasPost = ["post-403", "post-500", "network"].includes(mode);
|
||||||
|
assert.equal(calls.length, hasPost ? 3 : mode === "user" ? 2 : 1, mode);
|
||||||
|
assert.equal(calls.filter(call => call.options.method === "POST").length, hasPost ? 1 : 0);
|
||||||
|
assert.equal(result.debugRequests.at(-1).status, ["empty", "multiple", "wrong-parent", "missing-country"].includes(mode) ? "success" : "failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const payload = () => ({ AccountPartyId: 100000000000101, OpportunityName_Id_c: 202,
|
||||||
|
ServiceNew_Id_c: 404, Title: "Service-OP1-Customer", RegionText_c: "LAD", CountryText_c: "BR",
|
||||||
|
ProblemDescription: "Test description", Language_c: "ENGLISH", CustomerDeadline_c: "2027-01-31",
|
||||||
|
TechPlatformLookUp_c: "NO_ALL_PLATFORM", PrimaryPillarNew_c: "Tech_Cloud_Infrastructure" });
|
||||||
|
const withClientId = (body, status, value) => {
|
||||||
|
const result = response(body, status);
|
||||||
|
result.headers.set("X-AppBuilder-Client-Id", value);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: response client ID from resource lookup appears in confirmation and token request`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (url.includes("/resourceUsers?")) return withClientId({ resourcePartyId: 303 }, 200, "response-client");
|
||||||
|
if (url.endsWith("/Catalogue_c/505")) return response({ result: "test-token" });
|
||||||
|
return response({ SrNumber: "SR1" }, 201);
|
||||||
|
} });
|
||||||
|
const prepared = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
|
||||||
|
assert.equal(prepared.ok, true);
|
||||||
|
assert.equal(prepared.headers.requestSrTokenCatalog["x-appbuilder-client-id"], "response-client");
|
||||||
|
assert.deepEqual(prepared.headers.requestPostServiceRequest, { ...prepared.headers.requestSrTokenCatalog,
|
||||||
|
"vb-proxy-header-x-oracle-ectoken": "(generated after confirmation)" });
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
const result = await h.dispatch({ type, phase: "confirm", catalogId: 505, payload: prepared.payload });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(calls[1].options.headers["x-appbuilder-client-id"], "response-client");
|
||||||
|
assert.deepEqual({ ...calls[2].options.headers }, { ...calls[1].options.headers, "vb-proxy-header-x-oracle-ectoken": "test-token" });
|
||||||
|
assert.equal(calls.filter(call => call.url.endsWith("/mgmt/serviceRequests")).length, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("response observation includes background responses and excludes other apps and private requests", async () => {
|
||||||
|
const h = loadBackground();
|
||||||
|
assert.equal(h.responseHeaderListeners.length, 1);
|
||||||
|
const capture = h.responseHeaderListeners[0];
|
||||||
|
assert.deepEqual(capture.options, ["responseHeaders"]);
|
||||||
|
const details = { tabId: -1,
|
||||||
|
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/example",
|
||||||
|
responseHeaders: [{ name: "X-AppBuilder-Client-ID", value: "background-response-client" }, { name: "Set-Cookie", value: "not-retained" }] };
|
||||||
|
capture.listener({ ...details, incognito: true });
|
||||||
|
capture.listener({ ...details, url: details.url.replace("SeaaS_initiation", "unrelated") });
|
||||||
|
assert.deepEqual(h.sessionStorage, {});
|
||||||
|
capture.listener(details);
|
||||||
|
assert.equal(Object.values(h.sessionStorage)[0].value, "background-response-client");
|
||||||
|
assert.ok(!JSON.stringify(h.sessionStorage).includes("not-retained"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("token 403 retries once only with a newly returned client ID; SR POST is never retried", async () => {
|
||||||
|
for (const mode of ["recovered", "still-forbidden", "unchanged", "no-header", "save-failure"]) {
|
||||||
|
const calls = [];
|
||||||
|
let tokens = 0;
|
||||||
|
const h = loadBackground({ fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (url.includes("/resourceUsers?")) return withClientId({ resourcePartyId: 303 }, 200, "old-client");
|
||||||
|
if (url.endsWith("/Catalogue_c/505")) {
|
||||||
|
tokens += 1;
|
||||||
|
if (tokens === 1) return mode === "no-header" ? response({}, 403)
|
||||||
|
: withClientId({}, 403, mode === "unchanged" ? "old-client" : "new-client");
|
||||||
|
if (mode === "still-forbidden") return withClientId({}, 403, "another-client");
|
||||||
|
return response({ result: "retry-token" });
|
||||||
|
}
|
||||||
|
return response({ SrNumber: "SR1" }, mode === "save-failure" ? 403 : 201);
|
||||||
|
} });
|
||||||
|
const prepared = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
|
||||||
|
const result = await h.dispatch({ type, phase: "confirm", catalogId: 505, payload: prepared.payload });
|
||||||
|
assert.equal(result.ok, mode === "recovered");
|
||||||
|
const expectedTokens = ["unchanged", "no-header"].includes(mode) ? 1 : 2;
|
||||||
|
assert.equal(tokens, expectedTokens);
|
||||||
|
const tokenCalls = calls.filter(call => call.url.endsWith("/Catalogue_c/505"));
|
||||||
|
assert.equal(tokenCalls[0].options.headers["x-appbuilder-client-id"], "old-client");
|
||||||
|
if (expectedTokens === 2) assert.equal(tokenCalls[1].options.headers["x-appbuilder-client-id"], "new-client");
|
||||||
|
assert.equal(calls.filter(call => call.url.endsWith("/mgmt/serviceRequests")).length, ["recovered", "save-failure"].includes(mode) ? 1 : 0);
|
||||||
|
if (["recovered", "save-failure"].includes(mode)) {
|
||||||
|
assert.deepEqual({ ...calls.at(-1).options.headers }, { ...tokenCalls.at(-1).options.headers, "vb-proxy-header-x-oracle-ectoken": "retry-token" });
|
||||||
|
}
|
||||||
|
assertDebugVisibility(result, "retry-token");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: Save resolves current user, discovers catalog, obtains token and creates SR once with protected diagnostics`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (calls.length === 1) return response({ emailAddress, resourceEmail: emailAddress, resourcePartyId: 303, resourceProfileId: 999 });
|
||||||
|
if (calls.length === 2) return response({ items: [{ Id: 505 }] });
|
||||||
|
if (calls.length === 3) return response({ result: "synthetic-secret-token" });
|
||||||
|
return response({ SrId: 606, SrNumber: "SR606" }, 201);
|
||||||
|
} });
|
||||||
|
const prepared = await h.dispatch({ type, phase: "prepare", emailAddress, payload: { ...payload(), ReportedByPartyId: 999 } });
|
||||||
|
assert.equal(prepared.ok, true);
|
||||||
|
assert.equal(calls.length, 1, "preview only resolves the user, never generates a token or creates SR");
|
||||||
|
assert.equal(h.headerRules.length, 0, "preview does not install request modifications");
|
||||||
|
assert.equal(prepared.headers.requestSrTokenCatalog["Content-Type"], "application/vnd.oracle.adf.action+json");
|
||||||
|
assert.equal(prepared.headers.requestPostServiceRequest["vb-proxy-header-x-oracle-ectoken"], "(generated after confirmation)");
|
||||||
|
assert.equal(prepared.payload.ReportedByPartyId, 303);
|
||||||
|
const result = await h.dispatch({ type, phase: "confirm", payload: prepared.payload });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.result.SrNumber, "SR606");
|
||||||
|
assert.equal(calls.length, 4);
|
||||||
|
assert.equal(calls[0].options.method, "GET");
|
||||||
|
assert.equal(new URL(calls[0].url).searchParams.get("emailAddress"), emailAddress);
|
||||||
|
assert.ok(calls[0].url.includes("/set/seaas/crm/resourceUsers?"));
|
||||||
|
assert.equal(calls[1].options.method, "GET");
|
||||||
|
assert.ok(calls[2].url.endsWith("/Catalogue_c/505"));
|
||||||
|
assert.deepEqual(JSON.parse(calls[2].options.body), { name: "getJwtToken" });
|
||||||
|
assert.equal(calls[2].options.headers["Content-Type"], "application/vnd.oracle.adf.action+json");
|
||||||
|
assert.equal(calls[2].options.headers.Origin, new URL(calls[2].url).origin);
|
||||||
|
assert.deepEqual({ ...calls[3].options.headers }, { ...calls[2].options.headers, "vb-proxy-header-x-oracle-ectoken": "synthetic-secret-token" });
|
||||||
|
assert.equal(h.headerRules.length, 1);
|
||||||
|
const rule = h.headerRules[0].addRules[0];
|
||||||
|
const serviceRule = h.headerRules[0].addRules[1];
|
||||||
|
assert.ok(new RegExp(serviceRule.condition.regexFilter).test(calls[3].url));
|
||||||
|
assert.ok(!new RegExp(serviceRule.condition.regexFilter).test(calls[2].url));
|
||||||
|
assert.ok(!new RegExp(serviceRule.condition.regexFilter).test(calls[3].url + "/other"));
|
||||||
|
assert.deepEqual(serviceRule.condition.initiatorDomains, ["synthetic-extension"]);
|
||||||
|
assert.deepEqual(serviceRule.condition.requestMethods, ["post"]);
|
||||||
|
assert.deepEqual(serviceRule.action, rule.action);
|
||||||
|
assert.deepEqual(rule.condition.initiatorDomains, ["synthetic-extension"]);
|
||||||
|
assert.deepEqual(rule.condition.requestMethods, ["post"]);
|
||||||
|
assert.ok(new RegExp(rule.condition.regexFilter).test(calls[2].url));
|
||||||
|
assert.ok(!new RegExp(rule.condition.regexFilter).test(calls[3].url));
|
||||||
|
assert.ok(!new RegExp(rule.condition.regexFilter).test(calls[2].url + "/other"));
|
||||||
|
assert.deepEqual(rule.action.requestHeaders, [{ header: "Origin", operation: "set", value: new URL(calls[2].url).origin }]);
|
||||||
|
assert.equal(calls[3].options.method, "POST");
|
||||||
|
assert.ok(calls[3].url.endsWith("/set/seaas/mgmt/serviceRequests"));
|
||||||
|
assert.equal(calls[3].options.headers["vb-proxy-header-x-oracle-ectoken"], "synthetic-secret-token");
|
||||||
|
assert.ok(calls.slice(0, 3).every(call => !call.options.headers["vb-proxy-header-x-oracle-ectoken"]));
|
||||||
|
assert.ok(calls.every(call => call.options.credentials === "include" && call.options.headers.Authorization === "Session"));
|
||||||
|
assert.deepEqual(JSON.parse(calls[3].options.body), { ...payload(), ReportedByPartyId: 303, SvcQuestionsCollection_c: [],
|
||||||
|
SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" });
|
||||||
|
assert.equal(calls[3].options.body, JSON.stringify(prepared.payload));
|
||||||
|
assertDebugVisibility(result, "synthetic-secret-token");
|
||||||
|
assert.equal(h.tabs.length, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("missing region, invalid dates or unsafe IDs stop before token acquisition", async () => {
|
||||||
|
const h = loadBackground({ fetch: async () => assert.fail("Unexpected network") });
|
||||||
|
for (const change of [{ RegionText_c: null }, { CustomerDeadline_c: "2027-02-30" },
|
||||||
|
{ AccountPartyId: "9007199254740993" }, { OpportunityName_Id_c: "bad/id" }]) {
|
||||||
|
assert.equal((await h.dispatch({ type, phase: "prepare", emailAddress, payload: { ...payload(), ...change } })).ok, false);
|
||||||
|
}
|
||||||
|
assert.equal((await h.dispatch({ type, phase: "prepare", payload: payload() })).ok, false);
|
||||||
|
assert.equal((await h.dispatch({ type, phase: "confirm", payload: payload() })).ok, false);
|
||||||
|
assert.equal((await h.dispatch({ type, emailAddress, payload: payload() })).ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: captures only SeaaS client ID, reuses it after worker restart and expires stale values`, async () => {
|
||||||
|
const tokenCalls = [];
|
||||||
|
const sessionStorage = {};
|
||||||
|
const fetch = async (url, options) => {
|
||||||
|
if (url.includes("/resourceUsers?")) return response({ resourcePartyId: 303 });
|
||||||
|
if (url.endsWith("/Catalogue_c/505")) {
|
||||||
|
tokenCalls.push(options);
|
||||||
|
return response({ result: "synthetic-token" });
|
||||||
|
}
|
||||||
|
return response({ SrNumber: "SR1" }, 201);
|
||||||
|
};
|
||||||
|
const h = loadBackground({ family, fetch, sessionStorage });
|
||||||
|
assert.equal(h.headerListeners.length, 1);
|
||||||
|
const capture = h.headerListeners[0];
|
||||||
|
assert.deepEqual(capture.options, ["requestHeaders"]);
|
||||||
|
assert.deepEqual(capture.filter.types, ["xmlhttprequest"]);
|
||||||
|
const request = { tabId: 12, url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/example",
|
||||||
|
requestHeaders: [{ name: "X-AppBuilder-Client-Id", value: "synthetic-client" },
|
||||||
|
{ name: "Cookie", value: "do-not-store-cookie" }, { name: "Authorization", value: "do-not-store-auth" }] };
|
||||||
|
capture.listener({ ...request, url: request.url.replace("SeaaS_initiation", "otherApp") });
|
||||||
|
capture.listener({ ...request, url: request.url.replace("comcipapic-oalprod.integration.ocp.oraclecloud.com", "other.example") });
|
||||||
|
capture.listener({ ...request, tabId: -1 });
|
||||||
|
capture.listener({ ...request, incognito: true });
|
||||||
|
capture.listener({ ...request, requestHeaders: [{ name: "x-appbuilder-client-id", value: "invalid\r\nheader" }] });
|
||||||
|
assert.deepEqual(sessionStorage, {});
|
||||||
|
capture.listener(request);
|
||||||
|
assert.equal(Object.keys(sessionStorage).length, 1);
|
||||||
|
assert.ok(!JSON.stringify(sessionStorage).includes("do-not-store"));
|
||||||
|
const prepared = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
|
||||||
|
assert.equal(prepared.headers.requestSrTokenCatalog["x-appbuilder-client-id"], "synthetic-client");
|
||||||
|
assert.equal(tokenCalls.length, 0);
|
||||||
|
const restarted = loadBackground({ family, fetch, sessionStorage });
|
||||||
|
const result = await restarted.dispatch({ type, phase: "confirm", catalogId: 505, payload: prepared.payload });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(tokenCalls[0].headers["x-appbuilder-client-id"], "synthetic-client");
|
||||||
|
assert.equal(result.debugRequests[0].metadata.appBuilderClientId, "Observed in SeaaS requests");
|
||||||
|
restarted.advance(60 * 60 * 1000 + 1);
|
||||||
|
const expired = await restarted.dispatch({ type, phase: "confirm", catalogId: 505, payload: prepared.payload });
|
||||||
|
assert.equal(expired.ok, true);
|
||||||
|
assert.equal(tokenCalls[1].headers["x-appbuilder-client-id"], undefined);
|
||||||
|
assert.equal(expired.debugRequests[0].metadata.appBuilderClientId, "Not observed in SeaaS requests");
|
||||||
|
capture.listener({ ...request, requestHeaders: [{ name: "x-appbuilder-client-id", value: "renewed-client" }] });
|
||||||
|
const refreshed = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
|
||||||
|
assert.equal(refreshed.headers.requestSrTokenCatalog["x-appbuilder-client-id"], "renewed-client");
|
||||||
|
assert.equal(h.tabs.length, 0, "header discovery does not add background page loads");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Origin rule failure blocks token acquisition and SR creation", async () => {
|
||||||
|
const h = loadBackground({ ruleError: true, fetch: async () => assert.fail("No request expected") });
|
||||||
|
const result = await h.dispatch({ type, phase: "confirm", catalogId: 505, payload: { ...payload(), ReportedByPartyId: 303 } });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /Rule permission denied/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Origin rule failure prevents the Internal SR POST", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ ruleError: true, fetch: async url => {
|
||||||
|
calls.push(url);
|
||||||
|
if (url.includes("/serviceRequests?")) return response({ items: [parentInfo()] });
|
||||||
|
assert.match(url, /\/resourceUsers\?/);
|
||||||
|
return response({ resourcePartyId: 303 });
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type: internalType, emailAddress, payload: internalInput() });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /Rule permission denied/);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource lookup failures or invalid resourcePartyId never obtain a token or create SR", async () => {
|
||||||
|
for (const body of [{ resourcePartyId: null }, { resourceProfileId: 999 }, { resourcePartyId: "9007199254740993" }, { resourcePartyId: 0 }, "unauthorized"]) {
|
||||||
|
let calls = 0;
|
||||||
|
const h = loadBackground({ fetch: async () => {
|
||||||
|
calls += 1;
|
||||||
|
return response(body, body === "unauthorized" ? 401 : 200);
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(result.debugRequests[0].name, "requestResourceUser");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("token rejection never creates SR; creation failures are never automatically retried", async () => {
|
||||||
|
for (const failure of ["token", "create", "empty-token", "network"]) {
|
||||||
|
let calls = 0;
|
||||||
|
const h = loadBackground({ fetch: async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) return response({ resourcePartyId: 303 });
|
||||||
|
if (calls === 2) return failure === "token" ? response({}, 401)
|
||||||
|
: response({ result: failure === "empty-token" ? "" : "secret-token" });
|
||||||
|
if (failure === "network") throw new Error("Network error");
|
||||||
|
return response({}, 500);
|
||||||
|
} });
|
||||||
|
const prepared = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
|
||||||
|
const result = await h.dispatch({ type, phase: "confirm", catalogId: 505, payload: prepared.payload });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(calls, failure === "token" || failure === "empty-token" ? 2 : 3);
|
||||||
|
assertDebugVisibility(result, "secret-token");
|
||||||
|
assert.equal(h.tabs.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
47
tests/srSessionPreparation.test.cjs
Normal file
47
tests/srSessionPreparation.test.cjs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
test("preparation runs once, shares pending work and reports loading and completion", async () => {
|
||||||
|
const app = loadApplication("content");
|
||||||
|
const progress = [];
|
||||||
|
let requests = 0;
|
||||||
|
let resolveUser, resolveSession;
|
||||||
|
app.currentUser.ensure = () => new Promise(resolve => { resolveUser = resolve; });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.activityProgress.update = (...args) => progress.push(args);
|
||||||
|
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = message => {
|
||||||
|
assert.equal(message.type, "opportunitiesExtension.prepareSrSession");
|
||||||
|
requests += 1;
|
||||||
|
return new Promise(resolve => { resolveSession = resolve; });
|
||||||
|
};
|
||||||
|
const first = app.srSessionPreparation.ensure();
|
||||||
|
assert.equal(app.srSessionPreparation.ensure(), first);
|
||||||
|
assert.equal(requests, 0, "identity must load first");
|
||||||
|
resolveUser();
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(progress, [["session", "running"]]);
|
||||||
|
resolveSession({ ok: true });
|
||||||
|
await first;
|
||||||
|
await app.srSessionPreparation.ensure();
|
||||||
|
assert.equal(requests, 1);
|
||||||
|
assert.deepEqual(progress.at(-1), ["session", "complete"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SALES_REP skips preparation; failed preparation does not auto-repeat or reject modal loading", async () => {
|
||||||
|
for (const allowed of [false, true]) {
|
||||||
|
const app = loadApplication("content");
|
||||||
|
let requests = 0;
|
||||||
|
const progress = [];
|
||||||
|
app.currentUser.ensure = async () => {};
|
||||||
|
app.currentUser.allowsCrmActivities = () => allowed;
|
||||||
|
app.activityProgress.update = (...args) => progress.push(args);
|
||||||
|
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async () => { requests += 1; return { ok: false }; };
|
||||||
|
await app.srSessionPreparation.ensure();
|
||||||
|
await app.srSessionPreparation.ensure();
|
||||||
|
assert.equal(requests, allowed ? 1 : 0);
|
||||||
|
assert.deepEqual(progress, allowed ? [["session", "running"], ["session", "error"]] : []);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -57,7 +57,7 @@ function loadContent({ fetch = unexpectedFetch, storage = new Map(), xsrf = null
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, cookies = () => [], storageError = false } = {}) {
|
function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, sessionStorage = {}, cookies = () => [], storageError = false, ruleError = false, messageTimeout = 2000 } = {}) {
|
||||||
const time = clock();
|
const time = clock();
|
||||||
const listeners = [];
|
const listeners = [];
|
||||||
const progress = [];
|
const progress = [];
|
||||||
@@ -65,7 +65,17 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
|
|||||||
const tabs = [];
|
const tabs = [];
|
||||||
const removedTabs = [];
|
const removedTabs = [];
|
||||||
const cookieQueries = [];
|
const cookieQueries = [];
|
||||||
const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) } } };
|
const headerListeners = [];
|
||||||
|
const responseHeaderListeners = [];
|
||||||
|
const headerRules = [];
|
||||||
|
const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) },
|
||||||
|
getURL: path => `${family === "firefox" ? "moz" : "chrome"}-extension://synthetic-extension${path}` },
|
||||||
|
webRequest: {
|
||||||
|
onBeforeSendHeaders: { addListener: (listener, filter, options) => headerListeners.push({ listener, filter: plain(filter), options: plain(options) }) },
|
||||||
|
onHeadersReceived: { addListener: (listener, filter, options) => responseHeaderListeners.push({ listener, filter: plain(filter), options: plain(options) }) }
|
||||||
|
},
|
||||||
|
declarativeNetRequest: { updateSessionRules: async rules => { if (ruleError) throw new Error("Rule permission denied"); headerRules.push(plain(rules)); } }
|
||||||
|
};
|
||||||
const readCookies = details => { cookieQueries.push(plain(details)); return cookies(details); };
|
const readCookies = details => { cookieQueries.push(plain(details)); return cookies(details); };
|
||||||
const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
|
const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
|
||||||
const store = value => Object.assign(storage, plain(value));
|
const store = value => Object.assign(storage, plain(value));
|
||||||
@@ -110,6 +120,10 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
|
|||||||
getAllCookieStores(callback) { callback([{ id: "test-store" }]); }
|
getAllCookieStores(callback) { callback([{ id: "test-store" }]); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
api.storage.session = {
|
||||||
|
get: async key => ({ [key]: sessionStorage[key] === undefined ? undefined : plain(sessionStorage[key]) }),
|
||||||
|
set: async value => Object.assign(sessionStorage, plain(value))
|
||||||
|
};
|
||||||
const context = {
|
const context = {
|
||||||
[family === "firefox" ? "browser" : "chrome"]: api,
|
[family === "firefox" ? "browser" : "chrome"]: api,
|
||||||
fetch, URL, Date: time.Date,
|
fetch, URL, Date: time.Date,
|
||||||
@@ -123,7 +137,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
|
|||||||
function dispatch(message) {
|
function dispatch(message) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let replies = 0;
|
let replies = 0;
|
||||||
const timer = setTimeout(() => reject(new Error("Message did not receive a response")), 2000);
|
const timer = setTimeout(() => reject(new Error("Message did not receive a response")), messageTimeout);
|
||||||
const pending = listeners[0](message, sender, result => {
|
const pending = listeners[0](message, sender, result => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
assert.equal(++replies, 1, "Message must receive exactly one response");
|
assert.equal(++replies, 1, "Message must receive exactly one response");
|
||||||
@@ -132,7 +146,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
|
|||||||
if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); }
|
if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { dispatch, listener: listeners[0], progress, requestDebug, tabs, removedTabs, cookieQueries, storage, advance: time.advance };
|
return { dispatch, listener: listeners[0], progress, requestDebug, tabs, removedTabs, cookieQueries, storage, sessionStorage, headerRules, headerListeners, responseHeaderListeners, advance: time.advance };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { root, plain, response, loadContent, loadBackground };
|
module.exports = { root, plain, response, loadContent, loadBackground };
|
||||||
|
|||||||
623
tests/workloadActionForm.test.cjs
Normal file
623
tests/workloadActionForm.test.cjs
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
const lookupData = () => Object.fromEntries(["ROLE", "NAME", "TEAM", "STATUS", "COMPLEXITY"].map(key => [
|
||||||
|
`WWB_ACTION_${key}`, [{ code: `${key}_CODE`, name: `${key} <synthetic>` }]
|
||||||
|
]));
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
let document;
|
||||||
|
class Element {
|
||||||
|
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.isConnected = true; this.text = ""; }
|
||||||
|
set textContent(value) { this.text = value; this.children = []; }
|
||||||
|
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
|
||||||
|
append(...nodes) { for (const node of nodes) { node.parent = this; this.children.push(node); } }
|
||||||
|
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), createElementNS: (_, tag) => new Element(tag),
|
||||||
|
getElementById: id => id === "opportunities-extension-modal" ? host : null,
|
||||||
|
listeners: {}, addEventListener(key, fn) { this.listeners[key] = fn; } };
|
||||||
|
const app = loadApplication("content", { document, window: {} });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.authSessionState.principal = "synthetic@example.invalid";
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", { items: [] });
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("B", { items: [] });
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = () => assert.fail("Form must not send requests");
|
||||||
|
const walk = node => [node, ...node.children.flatMap(walk)];
|
||||||
|
const nodes = () => walk(host);
|
||||||
|
const form = () => nodes().find(node => node.tag === "form");
|
||||||
|
return { app, host, document, nodes, form };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("missing SR plus opens pillar form, handles retry, ignores closed requests and respects roles", async () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.opportunitiesTableState.opportunitiesTableState.items = [
|
||||||
|
{ OptyNumber: "A", Name: "<Opportunity A>", CustomerAccount: { PartyUniqueName: "Customer A" }, EffectiveDate: "2026-11-30" }
|
||||||
|
];
|
||||||
|
h.app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
|
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{ SrNumber: "SR1", OpportunityName_Id_c: { OptyNumber: "B" } }];
|
||||||
|
h.app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
await h.app.opportunityCrmServiceRequests.load("test", 1);
|
||||||
|
const calls = [];
|
||||||
|
const extraCalls = [];
|
||||||
|
h.app.currentUser.getUserId = () => "user@example.invalid";
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = message => {
|
||||||
|
if (message.type.endsWith("requestSrLookupPrimaryPillar")) return new Promise(resolve => calls.push({ message, resolve }));
|
||||||
|
extraCalls.push(message);
|
||||||
|
return Promise.resolve({ ok: true, items: message.type.endsWith("requestSrLookupOpportunityBusiness")
|
||||||
|
? [{ Id: "123", RecordName: "Service", Abstract_c: "<Description>" }]
|
||||||
|
: [{ code: "CODE", name: "Label" }] });
|
||||||
|
};
|
||||||
|
const badge = h.app.opportunityCrmServiceRequests.createBadge("A");
|
||||||
|
const plus = badge.children.find(node => node.tag === "button");
|
||||||
|
assert.ok(plus);
|
||||||
|
assert.equal(plus.textContent, "+");
|
||||||
|
assert.ok(!h.app.opportunityCrmServiceRequests.createBadge("B").children.some(node => node.className === "opportunities-extension-add-action"));
|
||||||
|
plus.click();
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Name", "Close Date"]);
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["Customer A", "<Opportunity A>", "30/11/2026"]);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].message.type, "opportunitiesExtension.requestSrLookupPrimaryPillar");
|
||||||
|
const field = () => h.nodes().find(node => node.name === "primaryPillar");
|
||||||
|
assert.equal(field().disabled, true);
|
||||||
|
calls[0].resolve({ ok: false, error: "Unauthorized" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.form().attributes["data-state"], "error");
|
||||||
|
assert.equal(extraCalls.length, 3);
|
||||||
|
assert.equal(extraCalls.find(call => call.type.endsWith("requestSrLookupOpportunityBusiness")).emailAddress, "user@example.invalid");
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "customerDeadline").type, "date");
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "description").tag, "textarea");
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "language").children[1].value, "CODE");
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "platform").children[1].textContent, "Label");
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "service").value, "123");
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "service").type, "radio");
|
||||||
|
assert.ok(h.nodes().some(node => node.textContent === "<Description>"));
|
||||||
|
h.nodes().find(node => node.textContent === "Retry" && !node.hidden).click();
|
||||||
|
calls[1].resolve({ ok: true, items: [{ code: "SaaS_HCM", name: "SaaS - HCM" }] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(field().disabled, false);
|
||||||
|
assert.equal(field().children[1].value, "SaaS_HCM");
|
||||||
|
assert.equal(field().children[1].textContent, "SaaS - HCM");
|
||||||
|
const search = h.nodes().find(node => node.id === `${field().id}-search`);
|
||||||
|
assert.equal(field().hidden, true);
|
||||||
|
assert.equal(search.disabled, false);
|
||||||
|
search.value = "hcm";
|
||||||
|
search.listeners.input();
|
||||||
|
const options = h.nodes().find(node => node.id === search.attributes["aria-controls"]);
|
||||||
|
assert.equal(options.children.length, 1);
|
||||||
|
assert.equal(options.children[0].textContent, "SaaS - HCM");
|
||||||
|
search.listeners.keydown({ key: "ArrowDown", preventDefault() {} });
|
||||||
|
search.listeners.keydown({ key: "Enter", preventDefault() {} });
|
||||||
|
assert.equal(field().value, "SaaS_HCM");
|
||||||
|
assert.equal(search.value, "SaaS - HCM");
|
||||||
|
assert.equal(options.hidden, true);
|
||||||
|
search.value = "does not exist";
|
||||||
|
search.listeners.input();
|
||||||
|
assert.equal(options.textContent, "No results");
|
||||||
|
search.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
|
||||||
|
assert.equal(search.value, "SaaS - HCM");
|
||||||
|
assert.ok(h.form(), "Escape closes only the options");
|
||||||
|
assert.equal(calls.length, 2, "searching does not issue another request");
|
||||||
|
h.nodes().find(node => node.textContent === "Cancel").click();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
h.app.opportunitiesTableState.opportunitiesTableState.items = [];
|
||||||
|
plus.click();
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["-", "-", "-"]);
|
||||||
|
h.app.opportunityCrmServiceRequests.reset();
|
||||||
|
calls[2].resolve({ ok: true, items: [] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
h.app.currentUser.allowsCrmActivities = () => false;
|
||||||
|
plus.click();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SR Save validates form, maps dynamic values, locks duplicate clicks and preserves failures", async () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
|
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [];
|
||||||
|
h.app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
await h.app.opportunityCrmServiceRequests.load("test", 1);
|
||||||
|
h.app.currentUser.getUserId = () => "current@example.invalid";
|
||||||
|
h.app.opportunitiesTableState.opportunitiesTableState.items = [{ OptyNumber: "B", CustomerAccount: { PartyId: 999, Region_c: "EMEA", PrimaryAddress: { CountryCode: "US" } } }, {
|
||||||
|
OptyNumber: "A", OptyId: 202, PartyId: 888, AccountPartyId: 777, Owner: { PartyId: 666 }, Region_c: "EMEA", RegionText_c: "LAD", Name: "Opportunity", CountryCode: "CA",
|
||||||
|
CustomerAccount: { PartyId: 100000000000101, PartyUniqueName: "Customer", Region_c: "APAC", CountryCode: "AR", PrimaryAddress: { CountryCode: "BR" } }
|
||||||
|
}];
|
||||||
|
const posts = [];
|
||||||
|
const notices = [];
|
||||||
|
h.app.opportunitiesLinks.showCopyConfirmation = (...args) => notices.push(args);
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = message => {
|
||||||
|
if (message.type.endsWith("requestPostServiceRequest")) return new Promise(resolve => posts.push({ message, resolve }));
|
||||||
|
return Promise.resolve({ ok: true, items: message.type.endsWith("requestSrLookupOpportunityBusiness")
|
||||||
|
? [{ Id: "404", RecordName: "Opportunity Pursuit", Abstract_c: "Description" }] : [{ code: "CODE", name: "Label" }] });
|
||||||
|
};
|
||||||
|
const plus = h.app.opportunityCrmServiceRequests.createBadge("A").children.find(node => node.tag === "button");
|
||||||
|
plus.click();
|
||||||
|
await flush();
|
||||||
|
const save = () => h.nodes().find(node => node.tag === "button" && ["Save", "Preparing...", "Saving..."].includes(node.textContent));
|
||||||
|
const submit = () => h.form().listeners.submit({ preventDefault() {} });
|
||||||
|
assert.equal(save().disabled, false);
|
||||||
|
await submit();
|
||||||
|
assert.equal(posts.length, 0);
|
||||||
|
assert.ok(h.nodes().some(node => node.attributes["aria-invalid"] === "true"));
|
||||||
|
for (const name of ["primaryPillar", "language", "platform"]) h.nodes().find(node => node.name === name).value = "CODE";
|
||||||
|
h.nodes().find(node => node.name === "customerDeadline").value = "2027-01-31";
|
||||||
|
h.nodes().find(node => node.name === "description").value = "Dynamic description";
|
||||||
|
h.nodes().find(node => node.name === "service").checked = true;
|
||||||
|
const saving = submit();
|
||||||
|
await submit();
|
||||||
|
assert.equal(posts.length, 1);
|
||||||
|
assert.equal(save().textContent, "Preparing...");
|
||||||
|
assert.equal(save().disabled, true);
|
||||||
|
h.nodes().find(node => node.textContent === "Cancel").click();
|
||||||
|
assert.ok(h.form(), "cannot dismiss while Save is in flight");
|
||||||
|
const payload = posts[0].message.payload;
|
||||||
|
assert.equal(payload.AccountPartyId, 100000000000101, "uses only the selected item's CustomerAccount.PartyId");
|
||||||
|
assert.equal(payload.OpportunityName_Id_c, 202);
|
||||||
|
assert.equal(payload.ReportedByPartyId, undefined);
|
||||||
|
assert.equal(posts[0].message.emailAddress, "current@example.invalid");
|
||||||
|
assert.equal(payload.Title, "Opportunity Pursuit-A-Customer");
|
||||||
|
assert.equal(payload.ServiceNew_Id_c, "404");
|
||||||
|
assert.equal(payload.PrimaryPillarNew_c, "CODE");
|
||||||
|
assert.equal(payload.Language_c, "CODE");
|
||||||
|
assert.equal(payload.TechPlatformLookUp_c, "CODE");
|
||||||
|
assert.equal(payload.CountryText_c, "BR", "uses the selected opportunity's CustomerAccount.PrimaryAddress.CountryCode, not CustomerAccount, root or another opportunity");
|
||||||
|
assert.equal(payload.RegionText_c, "APAC", "uses CustomerAccount.Region_c from the selected opportunity, not a root-level region or another opportunity");
|
||||||
|
assert.equal(payload.ProblemDescription, "Dynamic description");
|
||||||
|
assert.equal(payload.CustomerDeadline_c, "2027-01-31");
|
||||||
|
assert.equal(posts[0].message.phase, "prepare");
|
||||||
|
const preparedPayload = { ...payload, ReportedByPartyId: 303, SvcQuestionsCollection_c: [],
|
||||||
|
SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
|
||||||
|
const preparedHeaders = {
|
||||||
|
requestSrTokenCatalog: { Authorization: "Session", "Content-Type": "application/vnd.oracle.adf.action+json", Origin: "https://example.invalid" },
|
||||||
|
requestPostServiceRequest: { Authorization: "Session", "Content-Type": "application/vnd.oracle.adf.action+json", Origin: "https://example.invalid", "vb-proxy-header-x-oracle-ectoken": "(generated after confirmation)" }
|
||||||
|
};
|
||||||
|
posts[0].resolve({ ok: true, payload: preparedPayload, headers: preparedHeaders });
|
||||||
|
await flush();
|
||||||
|
const confirmation = () => h.nodes().find(node => node.className?.includes("opportunities-extension-sr-confirmation"));
|
||||||
|
const confirm = () => h.nodes().find(node => node.textContent === "Confirm Save");
|
||||||
|
assert.ok(confirmation());
|
||||||
|
const preview = h.nodes().find(node => node.tag === "pre");
|
||||||
|
assert.equal(preview.textContent, JSON.stringify(preparedPayload, null, 2));
|
||||||
|
const previews = h.nodes().filter(node => node.tag === "pre");
|
||||||
|
assert.equal(previews.length, 3);
|
||||||
|
assert.equal(previews[1].textContent, JSON.stringify(preparedHeaders.requestSrTokenCatalog, null, 2));
|
||||||
|
assert.equal(previews[2].textContent, JSON.stringify(preparedHeaders.requestPostServiceRequest, null, 2));
|
||||||
|
assert.ok(h.nodes().some(node => node.textContent === "x-appbuilder-client-id: not yet observed in SeaaS requests."));
|
||||||
|
assert.equal(h.form().parent.inert, true);
|
||||||
|
assert.equal(h.document.activeElement.textContent, "Cancel");
|
||||||
|
await submit();
|
||||||
|
assert.equal(posts.length, 1, "awaiting confirmation must never send a creation request");
|
||||||
|
confirm().focus();
|
||||||
|
confirmation().listeners.keydown({ key: "Tab", preventDefault() {}, stopPropagation() {} });
|
||||||
|
assert.equal(h.document.activeElement, preview);
|
||||||
|
h.nodes().filter(node => node.tag === "button" && node.textContent === "Cancel").at(-1).click();
|
||||||
|
await saving;
|
||||||
|
assert.equal(posts.length, 1, "Cancel never creates SR");
|
||||||
|
assert.equal(confirmation(), undefined);
|
||||||
|
assert.equal(h.form().parent.inert, false);
|
||||||
|
assert.equal(save().disabled, false);
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "description").value, "Dynamic description");
|
||||||
|
assert.equal(h.document.activeElement, save());
|
||||||
|
assert.equal(notices.length, 0);
|
||||||
|
|
||||||
|
const escaped = submit();
|
||||||
|
posts[1].resolve({ ok: true, payload: preparedPayload });
|
||||||
|
await flush();
|
||||||
|
confirmation().listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
|
||||||
|
await escaped;
|
||||||
|
assert.equal(posts.length, 2);
|
||||||
|
assert.ok(h.form(), "Escape closes only the confirmation");
|
||||||
|
|
||||||
|
const failed = submit();
|
||||||
|
posts[2].resolve({ ok: true, payload: preparedPayload });
|
||||||
|
await flush();
|
||||||
|
const confirmButton = confirm();
|
||||||
|
confirmButton.click();
|
||||||
|
confirmButton.click();
|
||||||
|
await flush();
|
||||||
|
assert.equal(posts.length, 4, "double confirm sends only one creation request");
|
||||||
|
assert.equal(posts[3].message.phase, "confirm");
|
||||||
|
assert.deepEqual(posts[3].message.payload, preparedPayload);
|
||||||
|
assert.equal(posts[3].message.payload.AccountPartyId, 100000000000101);
|
||||||
|
assert.equal(save().textContent, "Saving...");
|
||||||
|
posts[3].resolve({ ok: false, error: "Synthetic failure" });
|
||||||
|
await failed;
|
||||||
|
assert.equal(notices.at(-1)[1], true);
|
||||||
|
delete h.app.opportunitiesTableState.opportunitiesTableState.items[1].CustomerAccount.CountryCode;
|
||||||
|
const retry = submit();
|
||||||
|
assert.equal(posts[4].message.payload.CountryText_c, "BR", "uses PrimaryAddress.CountryCode even without CustomerAccount.CountryCode");
|
||||||
|
posts[4].resolve({ ok: true, payload: preparedPayload });
|
||||||
|
await flush();
|
||||||
|
confirm().click();
|
||||||
|
await flush();
|
||||||
|
posts[5].resolve({ ok: true, result: { SrId: 505, SrNumber: "SR505" } });
|
||||||
|
await retry;
|
||||||
|
assert.equal(h.form(), undefined);
|
||||||
|
assert.equal(notices.at(-1)[0], "Service Request saved successfully.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only plus opens the nine-field form; a missing workload stays disabled without a save operation", () => {
|
||||||
|
const h = setup();
|
||||||
|
const badge = h.app.opportunityWorkloadActions.createBadge("A");
|
||||||
|
assert.equal(badge.textContent, "0+");
|
||||||
|
badge.click();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
const plus = badge.children.find(node => node.tag === "button");
|
||||||
|
assert.equal(plus.textContent, "+");
|
||||||
|
assert.equal(plus.attributes["aria-haspopup"], "dialog");
|
||||||
|
plus.click();
|
||||||
|
assert.equal(h.host.children.length, 1);
|
||||||
|
assert.equal(h.host.children[0].attributes.role, "dialog");
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.name).map(node => node.name), ["owner", "role", "action", "team", "startDate", "endDate", "status", "complexity", "notes"]);
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "notes").tag, "textarea");
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "startDate").type, "date");
|
||||||
|
assert.equal(h.document.activeElement.title, "Close");
|
||||||
|
assert.ok(h.nodes().filter(node => node.name).every(node => node.disabled));
|
||||||
|
assert.equal(h.form().attributes["data-state"], "error");
|
||||||
|
assert.equal(h.nodes().find(node => node.textContent === "Save").disabled, true);
|
||||||
|
let prevented = false;
|
||||||
|
h.form().listeners.submit({ preventDefault() { prevented = true; } });
|
||||||
|
assert.equal(prevented, true);
|
||||||
|
h.nodes().find(node => node.textContent === "Cancel").click();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
assert.equal(h.document.activeElement, plus);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("form traps focus, handles Escape, replaces rather than duplicates, and resets its fields", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.contentModal.ensureEscapeKeyHandler();
|
||||||
|
const first = h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button");
|
||||||
|
first.click();
|
||||||
|
h.nodes().find(node => node.name === "notes").value = "Draft";
|
||||||
|
const second = h.app.opportunityWorkloadActions.createBadge("B").children.find(node => node.tag === "button");
|
||||||
|
second.click();
|
||||||
|
assert.equal(h.host.children.length, 1);
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "notes").value, undefined);
|
||||||
|
const cancel = h.nodes().find(node => node.textContent === "Cancel");
|
||||||
|
cancel.focus();
|
||||||
|
let prevented = false;
|
||||||
|
h.host.children[0].listeners.keydown({ key: "Tab", preventDefault() { prevented = true; } });
|
||||||
|
assert.equal(prevented, true);
|
||||||
|
assert.equal(h.document.activeElement.title, "Close");
|
||||||
|
h.document.listeners.keydown({ key: "Escape", preventDefault() {} });
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
assert.equal(h.document.activeElement, second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SALES_REP, loading/unknown Actions and stale triggers cannot open the modal", () => {
|
||||||
|
const h = setup();
|
||||||
|
const plus = h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button");
|
||||||
|
h.app.currentUser.allowsCrmActivities = () => false;
|
||||||
|
plus.click();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
assert.equal(h.app.opportunityWorkloadActions.createBadge("A"), null);
|
||||||
|
h.app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
h.app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "pending" }] });
|
||||||
|
plus.click();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
assert.equal(h.app.opportunityWorkloadActions.createBadge("A").children.some(node => node.tag === "button"), false);
|
||||||
|
h.app.authSessionState.principal = "";
|
||||||
|
assert.equal(h.app.opportunityWorkloadActions.createBadge("B").children.some(node => node.tag === "button"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resetting Actions closes an open form without leaving an old opportunity context", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button").click();
|
||||||
|
h.app.opportunityWorkloadActions.reset();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Action header shows the selected opportunity customer, name and formatted close date safely", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.opportunitiesTableState.opportunitiesTableState.items = [
|
||||||
|
{ OptyNumber: "A", Name: "<script>synthetic</script>", CustomerAccount: { PartyUniqueName: "Customer A" }, EffectiveDate: "2026-11-30" },
|
||||||
|
{ OptyNumber: "B", Name: "Opportunity B", CustomerAccount: { PartyUniqueName: "Customer B" }, EffectiveDate: null }
|
||||||
|
];
|
||||||
|
h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button").click();
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Name", "Close Date"]);
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["Customer A", "<script>synthetic</script>", "30/11/2026"]);
|
||||||
|
assert.equal(h.nodes().some(node => node.tag === "script"), false);
|
||||||
|
h.app.opportunityWorkloadActions.createBadge("B").children.find(node => node.tag === "button").click();
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["Customer B", "Opportunity B", "-"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function optionsSetup(products = [{ RevnId: "123", WorkloadName_c: "Synthetic workload" }]) {
|
||||||
|
const h = setup();
|
||||||
|
h.calls = [];
|
||||||
|
h.app.productsContentState.opptyProductsByNumber.set("A", { items: products });
|
||||||
|
h.app.opportunityWorkloadActions.getSummary = () => ({ state: "missing" });
|
||||||
|
h.app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
|
||||||
|
h.app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = message => new Promise((resolve, reject) => h.calls.push({ message, resolve, reject }));
|
||||||
|
h.field = name => h.nodes().find(node => node.name === name);
|
||||||
|
h.open = () => h.app.workloadActionForm.open("A", null);
|
||||||
|
h.workload = () => h.nodes().find(node => node.id === "opportunities-extension-action-form-workload");
|
||||||
|
h.completeLookup = () => h.calls.find(call => call.message.type.endsWith("requestActionFindLookup")).resolve({ ok: true, lookups: lookupData(), debugRequests: [] });
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("both options requests must succeed before fields enable; email and lookup codes stay distinct from labels", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
h.open();
|
||||||
|
assert.equal(h.form().attributes["aria-busy"], "true");
|
||||||
|
assert.ok(h.nodes().filter(node => node.name).every(node => node.disabled));
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.length, 2);
|
||||||
|
assert.deepEqual(h.calls.map(call => call.message.type), ["opportunitiesExtension.requestActionFindLookup", "opportunitiesExtension.requestActionTeamMembers"]);
|
||||||
|
const team = h.calls[1];
|
||||||
|
assert.equal(team.message.workloadId, "123");
|
||||||
|
for (const call of h.calls) assert.equal(call.message.authorization.value, "synthetic-wwb");
|
||||||
|
team.resolve({ ok: true, members: [{ email: "owner@example.invalid" }], debugRequests: [] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.field("owner").disabled, true, "a partial result cannot enable the form");
|
||||||
|
h.completeLookup();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.form().attributes["data-state"], "ready");
|
||||||
|
assert.equal(h.form().attributes["aria-busy"], "false");
|
||||||
|
assert.equal(h.field("owner").tag, "select");
|
||||||
|
assert.equal(h.field("owner").children[1].textContent, "owner@example.invalid");
|
||||||
|
assert.equal(h.field("owner").children[1].value, "owner@example.invalid");
|
||||||
|
assert.equal(h.field("owner").value, "", "unmatched user is not replaced by the first owner");
|
||||||
|
for (const [name, key] of [["role", "ROLE"], ["action", "NAME"], ["team", "TEAM"], ["status", "STATUS"], ["complexity", "COMPLEXITY"]]) {
|
||||||
|
const field = h.field(name);
|
||||||
|
assert.equal(field.tag, "select");
|
||||||
|
assert.equal(field.disabled, false);
|
||||||
|
assert.equal(field.children[1].value, `${key}_CODE`);
|
||||||
|
assert.equal(field.children[1].textContent, `${key} <synthetic>`);
|
||||||
|
assert.equal(field.children[1].children.length, 0);
|
||||||
|
}
|
||||||
|
assert.equal(h.document.activeElement.id, `${h.field("owner").id}-search`);
|
||||||
|
assert.equal(h.nodes().find(node => node.textContent === "Save").disabled, false);
|
||||||
|
let prevented = false;
|
||||||
|
h.form().listeners.submit({ preventDefault() { prevented = true; } });
|
||||||
|
assert.equal(prevented, true);
|
||||||
|
assert.equal(h.calls.length, 2, "no save request");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Action defaults use current user and lookup labels; only Notes is optional", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
const identity = h.app.currentUser.ensure();
|
||||||
|
h.calls[0].resolve({ ok: true, user: { id: "OWNER@example.invalid", roles: ["SIMPLE_USER"] } });
|
||||||
|
await identity;
|
||||||
|
assert.equal(h.app.currentUser.getUserId(), "OWNER@example.invalid");
|
||||||
|
h.calls.length = 0;
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
const data = lookupData();
|
||||||
|
data.WWB_ACTION_ROLE = [{ code: "CLOUD_ARCHITECT", name: "Cloud Architect" }];
|
||||||
|
data.WWB_ACTION_NAME = [{ code: "SUCCESS_PLAN_CONSUM_RVW", name: "Success Plan & Consumption review" }];
|
||||||
|
data.WWB_ACTION_STATUS = [{ code: "COMPLETE", name: "Complete" }];
|
||||||
|
h.calls[0].resolve({ ok: true, lookups: data });
|
||||||
|
h.calls[1].resolve({ ok: true, members: [{ email: "other@example.invalid" }, { email: "owner@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
for (const [name, value, label] of [
|
||||||
|
["owner", "owner@example.invalid", "owner@example.invalid"],
|
||||||
|
["role", "CLOUD_ARCHITECT", "Cloud Architect"],
|
||||||
|
["action", "SUCCESS_PLAN_CONSUM_RVW", "Success Plan & Consumption review"],
|
||||||
|
["status", "COMPLETE", "Complete"]
|
||||||
|
]) {
|
||||||
|
assert.equal(h.field(name).value, value);
|
||||||
|
assert.equal(h.nodes().find(node => node.id === `${h.field(name).id}-search`).value, label);
|
||||||
|
}
|
||||||
|
for (const name of ["owner", "role", "action", "team", "startDate", "endDate", "status", "complexity"]) {
|
||||||
|
const field = h.field(name);
|
||||||
|
assert.equal(field.required, true);
|
||||||
|
assert.equal(field.attributes["aria-required"], "true");
|
||||||
|
assert.ok(field.parent.children[0].textContent.endsWith("(required)"));
|
||||||
|
if (field.tag === "select") {
|
||||||
|
assert.equal(h.nodes().find(node => node.id === `${field.id}-search`).required, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(h.field("team").value, "");
|
||||||
|
assert.equal(h.field("complexity").value, "");
|
||||||
|
assert.equal(h.field("notes").required, false);
|
||||||
|
assert.equal(h.field("notes").parent.children[0].textContent, "Notes (optional)");
|
||||||
|
assert.equal(h.calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Save validates, prevents duplicate POSTs, preserves errors and reports success", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
h.app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
const notifications = [];
|
||||||
|
h.app.opportunitiesLinks.showCopyConfirmation = (...args) => notifications.push(args);
|
||||||
|
h.app.currentUser.getUserId = () => "creator@example.invalid";
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
h.completeLookup();
|
||||||
|
h.calls[1].resolve({ ok: true, members: [{ email: "owner@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
const submit = () => h.form().listeners.submit({ preventDefault() {} });
|
||||||
|
await submit();
|
||||||
|
assert.equal(h.calls.length, 2);
|
||||||
|
assert.equal(h.nodes().find(node => node.id === `${h.field("owner").id}-search`).attributes["aria-invalid"], "true");
|
||||||
|
for (const name of ["owner", "role", "action", "team", "status", "complexity"]) h.field(name).value = h.field(name).children[1].value;
|
||||||
|
h.field("startDate").value = "2026-09-14";
|
||||||
|
h.field("endDate").value = "2026-09-13";
|
||||||
|
await submit();
|
||||||
|
assert.equal(h.calls.length, 2);
|
||||||
|
assert.equal(h.field("endDate").attributes["aria-invalid"], "true");
|
||||||
|
h.field("endDate").value = "2026-09-15";
|
||||||
|
h.field("notes").value = "My note";
|
||||||
|
const first = submit();
|
||||||
|
await flush();
|
||||||
|
await submit();
|
||||||
|
assert.equal(h.calls.length, 3);
|
||||||
|
const call = h.calls[2];
|
||||||
|
assert.equal(call.message.type, "opportunitiesExtension.requestMgmtAction");
|
||||||
|
assert.equal(call.message.authorization.value, "synthetic-wwb");
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(call.message.payload)), {
|
||||||
|
parentType: "WORKLOAD", parentId: "123", action: {
|
||||||
|
ownerEmail: "owner@example.invalid", role: "ROLE_CODE", name: "NAME_CODE", team: "TEAM_CODE",
|
||||||
|
tags: "", startDate: "2026-09-14T00:00:00.000Z", endDate: "2026-09-15T00:00:00.000Z",
|
||||||
|
status: "STATUS_CODE", complexity: "COMPLEXITY_CODE", notes: ["My note"], objective: "",
|
||||||
|
createdBy: "creator@example.invalid", updatedBy: "creator@example.invalid"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
call.resolve({ ok: false, error: "HTTP 403" });
|
||||||
|
await first;
|
||||||
|
assert.equal(h.field("notes").value, "My note");
|
||||||
|
assert.deepEqual(notifications[0], ["HTTP 403", true]);
|
||||||
|
h.field("notes").value = "";
|
||||||
|
const second = submit();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls[3].message.payload.action.notes.length, 0);
|
||||||
|
h.calls[3].resolve({ ok: true, result: { id: 77, ownerEmail: "synthetic@example.invalid", name: "REVIEW" } });
|
||||||
|
await second;
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
assert.deepEqual(notifications[1], ["Action saved successfully."]);
|
||||||
|
assert.equal(h.app.opportunityWorkloadActions.createBadge("A").attributes["data-state"], "matched");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("autocomplete searches labels and codes, selects by keyboard and restores committed values", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
const data = lookupData();
|
||||||
|
data.WWB_ACTION_ROLE = [{ code: "CA", name: "Cloud Architect" }, { code: "SE", name: "Solution Engineer" }];
|
||||||
|
h.calls[0].resolve({ ok: true, lookups: data });
|
||||||
|
h.calls[1].resolve({ ok: true, members: [{ email: "owner@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
const input = h.nodes().find(node => node.id === `${h.field("role").id}-search`);
|
||||||
|
const list = h.nodes().find(node => node.id === input.attributes["aria-controls"]);
|
||||||
|
list.style = {};
|
||||||
|
input.closest = () => ({ getBoundingClientRect: () => ({ top: 20, bottom: 600 }) });
|
||||||
|
input.getBoundingClientRect = () => ({ top: 100, bottom: 138 });
|
||||||
|
const key = key => input.listeners.keydown({ key, preventDefault() {}, stopPropagation() {} });
|
||||||
|
input.value = "architect";
|
||||||
|
input.listeners.input();
|
||||||
|
assert.equal(list.attributes["data-placement"], "below");
|
||||||
|
assert.equal(list.style.maxHeight, "180px");
|
||||||
|
assert.equal(list.children.length, 1);
|
||||||
|
assert.equal(list.children[0].textContent, "Cloud Architect");
|
||||||
|
key("ArrowDown");
|
||||||
|
assert.equal(list.children[0].attributes["aria-selected"], "true");
|
||||||
|
key("Enter");
|
||||||
|
assert.equal(h.field("role").value, "CA");
|
||||||
|
assert.equal(input.value, "Cloud Architect");
|
||||||
|
assert.equal(list.hidden, true);
|
||||||
|
input.value = "not present";
|
||||||
|
input.getBoundingClientRect = () => ({ top: 500, bottom: 538 });
|
||||||
|
input.listeners.input();
|
||||||
|
assert.equal(list.attributes["data-placement"], "above");
|
||||||
|
assert.equal(list.style.maxHeight, "180px");
|
||||||
|
assert.equal(list.textContent, "No results");
|
||||||
|
key("Escape");
|
||||||
|
assert.equal(input.value, "Cloud Architect");
|
||||||
|
input.value = "SE";
|
||||||
|
input.listeners.input();
|
||||||
|
list.children[0].click();
|
||||||
|
assert.equal(h.field("role").value, "SE");
|
||||||
|
assert.equal(input.value, "Solution Engineer");
|
||||||
|
input.value = "free text";
|
||||||
|
input.listeners.blur();
|
||||||
|
assert.equal(input.value, "Solution Engineer");
|
||||||
|
assert.equal(h.calls.length, 2, "searching never issues requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workload autocomplete uses the selected RevnId and disables metadata search during loading", async () => {
|
||||||
|
const h = optionsSetup([{ RevnId: "123", WorkloadName_c: "First" }, { RevnId: "456", WorkloadName_c: "Second" }]);
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
const input = h.nodes().find(node => node.id === `${h.workload().id}-search`);
|
||||||
|
input.value = "second";
|
||||||
|
input.listeners.input();
|
||||||
|
input.listeners.keydown({ key: "Enter", preventDefault() {} });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.workload().value, "456");
|
||||||
|
assert.equal(h.calls[1].message.workloadId, "456");
|
||||||
|
assert.ok(h.nodes().filter(node => node.attributes.role === "combobox" && node !== input).every(node => node.disabled));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple workloads require selection, share the lookup and ignore an older member response", async () => {
|
||||||
|
const h = optionsSetup([{ RevnId: "123", WorkloadName_c: "First" }, { RevnId: "456", WorkloadName_c: "Second" }, { RevnId: "123" }]);
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.length, 1);
|
||||||
|
assert.equal(h.form().attributes["data-state"], "waiting");
|
||||||
|
const workload = h.workload();
|
||||||
|
assert.equal(workload.children.length, 3, "duplicate revenue IDs do not duplicate workloads");
|
||||||
|
assert.equal(workload.parent.hidden, false);
|
||||||
|
workload.value = "123";
|
||||||
|
workload.listeners.change();
|
||||||
|
await flush();
|
||||||
|
workload.value = "456";
|
||||||
|
workload.listeners.change();
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(h.calls.slice(1).map(call => call.message.workloadId), ["123", "456"]);
|
||||||
|
h.completeLookup();
|
||||||
|
h.calls[2].resolve({ ok: true, members: [{ email: "second@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.field("owner").children[1].value, "second@example.invalid");
|
||||||
|
h.calls[1].resolve({ ok: true, members: [{ email: "first@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.field("owner").children[1].value, "second@example.invalid");
|
||||||
|
assert.equal(h.form().attributes["data-state"], "ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errors remain local, register diagnostics under Actions and can be retried without duplicate requests", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
h.completeLookup();
|
||||||
|
h.calls[1].resolve({ ok: false, error: "Synthetic HTTP 403", debugRequests: [{
|
||||||
|
name: "requestActionTeamMembers", status: "failed", httpStatus: 403, startedAt: "2026-09-28T12:00:00Z",
|
||||||
|
completedAt: "2026-09-28T12:00:01Z", durationMs: 1000, requestHeaders: { "Wwb-Provider-Authorization": "(present)" }
|
||||||
|
}] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.form().attributes["data-state"], "error");
|
||||||
|
assert.equal(h.field("owner").disabled, true);
|
||||||
|
assert.equal(h.field("owner").children[0].textContent, "Unavailable");
|
||||||
|
const retry = h.nodes().find(node => node.textContent === "Retry");
|
||||||
|
assert.equal(retry.hidden, false);
|
||||||
|
assert.equal(h.app.debugState.requestLog.length, 1);
|
||||||
|
assert.equal(h.app.debugContentView.getRequestCategory(h.app.debugState.requestLog[0]), "actions");
|
||||||
|
assert.equal(h.app.debugContentView.getRequestCategory({ name: "requestActionFindLookup" }), "actions");
|
||||||
|
retry.click(); retry.click();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.length, 4);
|
||||||
|
h.calls[2].resolve({ ok: true, lookups: lookupData() });
|
||||||
|
h.calls[3].resolve({ ok: true, members: [] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.form().attributes["data-state"], "ready");
|
||||||
|
assert.equal(h.field("owner").disabled, true);
|
||||||
|
assert.equal(h.field("owner").children[0].textContent, "No options available");
|
||||||
|
assert.equal(h.field("role").disabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing or reopening the form discards late responses and waits for fresh options", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
const previous = h.form();
|
||||||
|
h.app.workloadActionForm.close(false);
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
h.completeLookup();
|
||||||
|
h.calls[1].resolve({ ok: true, members: [{ email: "old@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
assert.notEqual(h.form(), previous);
|
||||||
|
assert.equal(h.form().attributes["data-state"], "loading");
|
||||||
|
h.calls[2].resolve({ ok: true, lookups: lookupData() });
|
||||||
|
h.calls[3].resolve({ ok: true, members: [{ email: "fresh@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.field("owner").children[1].value, "fresh@example.invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing before authentication finishes prevents both options requests", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
let finish;
|
||||||
|
const auth = new Promise(resolve => { finish = resolve; });
|
||||||
|
h.app.authContentSession.requestConsumerTokenServiceOnce = () => auth;
|
||||||
|
h.open();
|
||||||
|
h.app.workloadActionForm.close(false);
|
||||||
|
finish({ workloadActions: { value: "synthetic" } });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.length, 0);
|
||||||
|
});
|
||||||
@@ -52,7 +52,9 @@ test("workload actions reject missing credentials, invalid ids, HTTP/network/JSO
|
|||||||
});
|
});
|
||||||
|
|
||||||
function contentFixture(send) {
|
function contentFixture(send) {
|
||||||
const app = loadApplication("content", { document: { createElement: () => ({ attributes: {}, setAttribute(k, v) { this.attributes[k] = v; } }) } });
|
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(k, v) { this.attributes[k] = v; } }) };
|
||||||
|
document.createElementNS = (_, tag) => document.createElement(tag);
|
||||||
|
const app = loadApplication("content", { document });
|
||||||
app.currentUser.allowsCrmActivities = () => true;
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
|
||||||
app.authSessionState.principal = "test@example.invalid";
|
app.authSessionState.principal = "test@example.invalid";
|
||||||
@@ -62,6 +64,108 @@ function contentFixture(send) {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("Actions progress counts product workloads, not opportunities, even with filters", async () => {
|
||||||
|
const pending = new Map();
|
||||||
|
const app = contentFixture(message => new Promise(resolve => pending.set(message.revnId, resolve)));
|
||||||
|
const progress = [];
|
||||||
|
app.activityProgress.update = (...args) => progress.push(args);
|
||||||
|
app.opportunitiesFilterState.tableSearch = "hidden";
|
||||||
|
const a = { items: [{ RevnId: "1" }, { RevnId: "2" }] };
|
||||||
|
const b = { items: [{ RevnId: "3" }] };
|
||||||
|
app.productsContentState.opptyProductsByNumber = new Map([["A", a], ["B", b]]);
|
||||||
|
const loading = [app.opportunityWorkloadActions.loadProducts(a), app.opportunityWorkloadActions.loadProducts(b)];
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.equal(pending.size, 3);
|
||||||
|
pending.get("1")({ ok: true, actions: [], hasNext: false });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(progress.at(-1), ["actions", "running", 1, 3]);
|
||||||
|
pending.get("2")({ ok: true, actions: [], hasNext: false });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(progress.at(-1), ["actions", "running", 2, 3]);
|
||||||
|
pending.get("3")({ ok: true, actions: [], hasNext: false });
|
||||||
|
await Promise.all(loading);
|
||||||
|
assert.deepEqual(progress.at(-1), ["actions", "complete", 3, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saved response updates the workload badge, tooltip and matching cache without fetching", async () => {
|
||||||
|
const app = contentFixture(() => assert.fail("Save response must not trigger a GET"));
|
||||||
|
const writes = [];
|
||||||
|
app.activityCache.write = async (...args) => writes.push(args);
|
||||||
|
const products = { items: [{ RevnId: "123" }], cachedAt: 1000 };
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", products);
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: "456" }] });
|
||||||
|
const action = { id: 77, name: "REVIEW", ownerEmail: "test@example.invalid", revenueId: 123, status: "COMPLETE" };
|
||||||
|
assert.equal(await app.opportunityWorkloadActions.applySavedAction("A", "123", action), true);
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions.length, 1);
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("B").actions.length, 0);
|
||||||
|
assert.ok(app.opportunityWorkloadActions.createBadge("A").title.includes("ID: 77"));
|
||||||
|
assert.deepEqual(writes[0].slice(0, 3), ["actions", "123", 1000]);
|
||||||
|
assert.equal(writes[0][3][0].id, 77);
|
||||||
|
await app.opportunityWorkloadActions.applySavedAction("A", "123", { action: { ...action, status: "OPEN" } });
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions.length, 1);
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions[0].status, "OPEN");
|
||||||
|
await app.opportunityWorkloadActions.applySavedAction("A", "123", { actions: [{ ...action, id: 78, ownerEmail: "other@example.invalid" }] });
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions.length, 2);
|
||||||
|
assert.equal(await app.opportunityWorkloadActions.applySavedAction("B", "123", action), false);
|
||||||
|
assert.equal(await app.opportunityWorkloadActions.applySavedAction("A", "123", null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saved Action uses its email rather than display name for green badge and removes plus", async () => {
|
||||||
|
const app = contentFixture(() => assert.fail("No follow-up GET"));
|
||||||
|
app.activityCache.write = async () => {};
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "123" }] });
|
||||||
|
const saved = { id: 90, owner: "Test User", ownerEmail: " TEST@example.invalid ", name: "REVIEW" };
|
||||||
|
await app.opportunityWorkloadActions.applySavedAction("A", "123", saved);
|
||||||
|
const badge = app.opportunityWorkloadActions.createBadge("A");
|
||||||
|
assert.equal(badge.attributes["data-state"], "matched");
|
||||||
|
assert.equal(badge.textContent, "1");
|
||||||
|
await app.opportunityWorkloadActions.applySavedAction("A", "123", { id: 90, owner: "Test User" }, { ownerEmail: "test@example.invalid" });
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
|
||||||
|
app.authSessionState.principal = "another@example.invalid";
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Actions completion includes queued calls, shared pending records, failures and cache writes", async () => {
|
||||||
|
const pending = [];
|
||||||
|
let finishWrite;
|
||||||
|
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
|
||||||
|
app.activityCache.read = async () => null;
|
||||||
|
app.activityCache.write = () => new Promise(resolve => { finishWrite = resolve; });
|
||||||
|
const products = { items: Array.from({ length: 5 }, (_, i) => ({ RevnId: String(i + 1) })) };
|
||||||
|
let complete = false, sharedComplete = false;
|
||||||
|
const batch = app.opportunityWorkloadActions.loadProducts(products, true, { cachedAt: 200 }).then(() => { complete = true; });
|
||||||
|
const shared = app.opportunityWorkloadActions.loadProducts(products).then(() => { sharedComplete = true; });
|
||||||
|
await flush();
|
||||||
|
assert.equal(pending.length, 4);
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
pending.slice(attempt * 4, attempt * 4 + 4).forEach(resolve => resolve({ ok: false, error: "Synthetic" }));
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
assert.equal(pending.length, 21);
|
||||||
|
assert.equal(complete, false);
|
||||||
|
assert.equal(sharedComplete, false);
|
||||||
|
pending[20]({ ok: true, actions: [], hasNext: false });
|
||||||
|
await flush();
|
||||||
|
assert.equal(complete, false, "cache write still pending");
|
||||||
|
finishWrite();
|
||||||
|
await batch;
|
||||||
|
await shared;
|
||||||
|
assert.equal(complete, true);
|
||||||
|
assert.equal(sharedComplete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Actions reset settles discarded queue entries without waiting for unstarted requests", async () => {
|
||||||
|
const pending = [];
|
||||||
|
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
|
||||||
|
const batch = app.opportunityWorkloadActions.loadProducts({ items: Array.from({ length: 8 }, (_, i) => ({ RevnId: String(i) })) });
|
||||||
|
await flush();
|
||||||
|
app.opportunityWorkloadActions.reset(false);
|
||||||
|
pending.forEach(resolve => resolve({ ok: true, actions: [], hasNext: false }));
|
||||||
|
await batch;
|
||||||
|
assert.equal(pending.length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
|
test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const app = contentFixture(async message => {
|
const app = contentFixture(async message => {
|
||||||
@@ -71,18 +175,18 @@ test("actions aggregate all products, share RevnId queries across Reference chan
|
|||||||
const products = { items: [{ RevnId: "1" }, { RevnId: 2 }, { RevnId: "1" }] };
|
const products = { items: [{ RevnId: "1" }, { RevnId: 2 }, { RevnId: "1" }] };
|
||||||
app.productsContentState.opptyProductsByNumber.set("A", products);
|
app.productsContentState.opptyProductsByNumber.set("A", products);
|
||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.opportunityWorkloadActions.loadProducts(products);
|
||||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ...");
|
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "...");
|
||||||
await flush();
|
await flush();
|
||||||
assert.equal(calls.length, 2);
|
assert.equal(calls.length, 2);
|
||||||
assert.equal(calls[0].authorization.value, "synthetic-wwb");
|
assert.equal(calls[0].authorization.value, "synthetic-wwb");
|
||||||
const badge = app.opportunityWorkloadActions.createBadge("A");
|
const badge = app.opportunityWorkloadActions.createBadge("A");
|
||||||
assert.equal(badge.textContent, "\u2705 Act");
|
assert.equal(badge.textContent, "1");
|
||||||
assert.match(badge.title, /<script>safe<\/script>/);
|
assert.match(badge.title, /<script>safe<\/script>/);
|
||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.opportunityWorkloadActions.loadProducts(products);
|
||||||
await flush();
|
await flush();
|
||||||
assert.equal(calls.length, 2);
|
assert.equal(calls.length, 2);
|
||||||
app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: 2 }] });
|
app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: 2 }] });
|
||||||
assert.equal(app.opportunityWorkloadActions.createBadge("B").textContent, "\u26d4 Act");
|
assert.equal(app.opportunityWorkloadActions.createBadge("B").textContent, "0");
|
||||||
app.opportunityWorkloadActions.reset(false);
|
app.opportunityWorkloadActions.reset(false);
|
||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.opportunityWorkloadActions.loadProducts(products);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -100,9 +204,9 @@ test("errors, missing ids and partial empty pages never appear as confirmed abse
|
|||||||
app.productsContentState.opptyProductsByNumber.set("A", products);
|
app.productsContentState.opptyProductsByNumber.set("A", products);
|
||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.opportunityWorkloadActions.loadProducts(products);
|
||||||
await flush();
|
await flush();
|
||||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
|
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
|
||||||
app.productsContentState.opptyProductsByNumber.set("A", { items: [{}] });
|
app.productsContentState.opptyProductsByNumber.set("A", { items: [{}] });
|
||||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
|
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,15 +221,17 @@ test("Act requires an owner matching tokenrelay principal while tooltip retains
|
|||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.opportunityWorkloadActions.loadProducts(products);
|
||||||
await flush();
|
await flush();
|
||||||
const badge = () => app.opportunityWorkloadActions.createBadge("A");
|
const badge = () => app.opportunityWorkloadActions.createBadge("A");
|
||||||
assert.equal(badge().textContent, "\u2705 Act");
|
assert.equal(badge().textContent, "2");
|
||||||
|
assert.equal(badge().attributes["data-state"], "matched");
|
||||||
assert.match(badge().title, /Other action/);
|
assert.match(badge().title, /Other action/);
|
||||||
assert.match(badge().title, /My action/);
|
assert.match(badge().title, /My action/);
|
||||||
app.authSessionState.principal = "different@example.invalid";
|
app.authSessionState.principal = "different@example.invalid";
|
||||||
assert.equal(badge().textContent, "\u26d4 Act", "ownerEmail must not determine ownership");
|
assert.equal(badge().textContent, "2");
|
||||||
|
assert.equal(badge().attributes["data-state"], "missing", "ownerEmail must not determine ownership");
|
||||||
assert.match(badge().title, /Other action/);
|
assert.match(badge().title, /Other action/);
|
||||||
assert.match(badge().title, /My action/);
|
assert.match(badge().title, /My action/);
|
||||||
app.authSessionState.principal = "";
|
app.authSessionState.principal = "";
|
||||||
assert.equal(badge().textContent, "Act ?");
|
assert.equal(badge().textContent, "?");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("general tokenrelay retains principal without changing access-token return or accepting sales-common identity", async () => {
|
test("general tokenrelay retains principal without changing access-token return or accepting sales-common identity", async () => {
|
||||||
@@ -158,7 +264,7 @@ test("actions queue caps concurrency and discards results/queued jobs from close
|
|||||||
assert.equal(app.opportunityWorkloadActions.createBadge("A"), null);
|
assert.equal(app.opportunityWorkloadActions.createBadge("A"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("product progress triggers Actions for cached products and ignores stale product jobs", async () => {
|
test("product progress triggers Actions only for fresh products and ignores stale product jobs", async () => {
|
||||||
let listener;
|
let listener;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const app = loadApplication("content", {
|
const app = loadApplication("content", {
|
||||||
@@ -173,14 +279,19 @@ test("product progress triggers Actions for cached products and ignores stale pr
|
|||||||
return { ok: true, actions: [], hasNext: false };
|
return { ok: true, actions: [], hasNext: false };
|
||||||
};
|
};
|
||||||
app.productsContentState.currentProductsRequestId = "current";
|
app.productsContentState.currentProductsRequestId = "current";
|
||||||
|
app.productActivities.begin("current", "synthetic");
|
||||||
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [];
|
||||||
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
||||||
const message = { type: app.messageTypes?.opptyProductsProgress || "opportunitiesExtension.opptyProductsProgress",
|
const message = { type: app.messageTypes?.opptyProductsProgress || "opportunitiesExtension.opptyProductsProgress",
|
||||||
requestId: "current", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: true };
|
requestId: "current", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: false };
|
||||||
listener({ ...message, requestId: "old" });
|
listener({ ...message, requestId: "old" });
|
||||||
await flush();
|
await flush();
|
||||||
assert.equal(calls.length, 0);
|
assert.equal(calls.length, 0);
|
||||||
listener(message);
|
listener(message);
|
||||||
await flush();
|
await flush();
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
listener({ ...message, optyNumber: undefined, state: "complete" });
|
||||||
|
await flush();
|
||||||
assert.equal(calls.length, 1);
|
assert.equal(calls.length, 1);
|
||||||
assert.equal(calls[0].type, "opportunitiesExtension.requestWlActions");
|
assert.equal(calls[0].type, "opportunitiesExtension.requestWlActions");
|
||||||
assert.equal(calls[0].revnId, "123");
|
assert.equal(calls[0].revnId, "123");
|
||||||
|
|||||||
Reference in New Issue
Block a user