9 Commits

76 changed files with 8806 additions and 413 deletions

View File

@@ -19,6 +19,16 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
## 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`
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
@@ -27,32 +37,92 @@ If identity or roles cannot be validated, only these two features stay disabled;
other opportunity features continue. Reload the page to refresh identity/roles.
The unused identity token is discarded and redacted from debug responses.
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`,
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
query endpoint, independently of the opportunity query version. Calls appear in
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
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.
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
in memory only. Reference changes share the in-flight lookup and reuse its result
(including failures) without another request. Closing resets this state; reopening
starts a fresh lookup, and responses from previous openings are ignored.
Badges follow Ramp Months. Successful SR and Actions responses are persisted in
extension local storage, isolated by tokenrelay principal. The cache uses the same
Preferences lifetime as Products (24 hours by default), anchored to the product
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 are queried in the background after product results arrive,
including cached products. Each distinct `RevnId` uses `requestWlActions` with
The missing Act badge includes a separate plus control that opens an Action form
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
aggregates all products: green means an action's `owner` matches the general
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
means ownership could not be confirmed (including a missing principal). Tooltips
retain all returned actions, regardless of owner. Principal stays in memory only.
Queries are shared per modal opening (up to four concurrently), reused
across Reference changes, and discarded on close. Reopening queries again.
retain all returned actions, regardless of owner. Credentials stay in memory only;
the principal is used to scope persisted activity entries.
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.
The debug panel groups these requests under Actions, with credentials masked.

View File

@@ -14,7 +14,17 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
requestSrLookupPrimaryPillar: "opportunitiesExtension.requestSrLookupPrimaryPillar",
prepareSrSession: "opportunitiesExtension.prepareSrSession",
requestSrLookupLanguage: "opportunitiesExtension.requestSrLookupLanguage",
requestSrLookupTechPlatform: "opportunitiesExtension.requestSrLookupTechPlatform",
requestSrLookupOpportunityBusiness: "opportunitiesExtension.requestSrLookupOpportunityBusiness",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
@@ -249,14 +259,22 @@ function createAuthConsumerSession({
const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE"
&& 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];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) {
if (needsSessionRecovery) {
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;
}
}
@@ -284,7 +302,7 @@ function createAuthConsumerSession({
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) {
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);
}
@@ -644,6 +662,295 @@ function createSpaWorkloadActions({ fetchJson }) {
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; }
}
return { requestLookupWithSession, requestInternalServiceRequest, requestSrLookupOpportunityBusiness,
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/spa/current-user.js
function createSpaCurrentUser({ fetchJson }) {
async function requestCurrentUser() {
@@ -852,7 +1159,7 @@ function createHttpBackgroundJson({
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 debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
let response;
@@ -873,7 +1180,10 @@ function createHttpBackgroundJson({
appendBackgroundDebugEntry(debugRequests, debugEntry);
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) {
@@ -881,6 +1191,7 @@ function createHttpBackgroundJson({
}
try {
if (allowEmpty && !responsePayload.text.trim()) return null;
return parseJsonResponse(responsePayload.text, requestName);
} catch (error) {
error.httpStatus = response.status;
@@ -1134,8 +1445,17 @@ function createProductsCache({
storageLocalGet,
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 = () => {}) {
const currentGeneration = generation;
const cache = await getProductsCache();
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
const cachedEntry = cache[optyNumber];
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
@@ -1153,15 +1473,14 @@ function createProductsCache({
})
.then(async (items) => {
const fetchedAt = Date.now();
cache[optyNumber] = {
fetchedAt,
items
};
if (generation === currentGeneration) {
cache[optyNumber] = { fetchedAt, items };
await saveProductsCache(cache).catch(() => {});
}
return { items, fetchedAt };
})
.finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber);
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
});
}
@@ -1207,7 +1526,7 @@ function createProductsCache({
return productsCacheState.productsCacheWritePromise;
}
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}
// Source: src/services/fusion/products.js
@@ -1720,12 +2039,23 @@ function startBackgroundApplication(dependencies) {
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
invalidateProductsCache,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestInternalServiceRequest,
requestSrLookupPrimaryPillar,
requestSrLookupWithSession,
prepareSrSession,
requestSrLookupLanguage,
requestSrLookupTechPlatform,
requestSrLookupOpportunityBusiness,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
@@ -1738,7 +2068,27 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
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.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) {
getXsrfTokenCookie()
.then((result) => {
@@ -1816,6 +2166,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
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) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",
@@ -2006,6 +2365,9 @@ function createBackgroundApplication(overrides = {}) {
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...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 requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
@@ -2013,8 +2375,17 @@ function createBackgroundApplication(overrides = {}) {
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
const fusionSrDetails = createFusionSrDetails({
cookieChanges: runtimeApi?.cookies?.onChanged,
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
wait: (...args) => platformBackgroundApi.wait(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
});
function start() {
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ prepareSrSession: sender => fusionSrDetails.prepareSession(sender), requestSrLookupWithSession: (...args) => fusionSrDetails.requestLookupWithSession(...args), messageTypes, requestSrLookupLanguage: () => fusionSrDetails.requestSrLookupLanguage(), requestSrLookupTechPlatform: () => fusionSrDetails.requestSrLookupTechPlatform(), requestSrLookupOpportunityBusiness: message => fusionSrDetails.requestSrLookupOpportunityBusiness(message), requestSrLookupPrimaryPillar: () => fusionSrDetails.requestSrLookupPrimaryPillar(), requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

1669
dist/chromium/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -9,7 +9,8 @@
],
"host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*"
"https://spa.oracle.com/*",
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
],
"content_scripts": [
{

View File

@@ -14,7 +14,17 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
requestSrLookupPrimaryPillar: "opportunitiesExtension.requestSrLookupPrimaryPillar",
prepareSrSession: "opportunitiesExtension.prepareSrSession",
requestSrLookupLanguage: "opportunitiesExtension.requestSrLookupLanguage",
requestSrLookupTechPlatform: "opportunitiesExtension.requestSrLookupTechPlatform",
requestSrLookupOpportunityBusiness: "opportunitiesExtension.requestSrLookupOpportunityBusiness",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
@@ -249,14 +259,22 @@ function createAuthConsumerSession({
const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE"
&& 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];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) {
if (needsSessionRecovery) {
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;
}
}
@@ -284,7 +302,7 @@ function createAuthConsumerSession({
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) {
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);
}
@@ -644,6 +662,295 @@ function createSpaWorkloadActions({ fetchJson }) {
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; }
}
return { requestLookupWithSession, requestInternalServiceRequest, requestSrLookupOpportunityBusiness,
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/spa/current-user.js
function createSpaCurrentUser({ fetchJson }) {
async function requestCurrentUser() {
@@ -852,7 +1159,7 @@ function createHttpBackgroundJson({
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 debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
let response;
@@ -873,7 +1180,10 @@ function createHttpBackgroundJson({
appendBackgroundDebugEntry(debugRequests, debugEntry);
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) {
@@ -881,6 +1191,7 @@ function createHttpBackgroundJson({
}
try {
if (allowEmpty && !responsePayload.text.trim()) return null;
return parseJsonResponse(responsePayload.text, requestName);
} catch (error) {
error.httpStatus = response.status;
@@ -1134,8 +1445,17 @@ function createProductsCache({
storageLocalGet,
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 = () => {}) {
const currentGeneration = generation;
const cache = await getProductsCache();
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
const cachedEntry = cache[optyNumber];
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
@@ -1153,15 +1473,14 @@ function createProductsCache({
})
.then(async (items) => {
const fetchedAt = Date.now();
cache[optyNumber] = {
fetchedAt,
items
};
if (generation === currentGeneration) {
cache[optyNumber] = { fetchedAt, items };
await saveProductsCache(cache).catch(() => {});
}
return { items, fetchedAt };
})
.finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber);
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
});
}
@@ -1207,7 +1526,7 @@ function createProductsCache({
return productsCacheState.productsCacheWritePromise;
}
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}
// Source: src/services/fusion/products.js
@@ -1720,12 +2039,23 @@ function startBackgroundApplication(dependencies) {
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
invalidateProductsCache,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestInternalServiceRequest,
requestSrLookupPrimaryPillar,
requestSrLookupWithSession,
prepareSrSession,
requestSrLookupLanguage,
requestSrLookupTechPlatform,
requestSrLookupOpportunityBusiness,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
@@ -1738,7 +2068,27 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
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.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) {
getXsrfTokenCookie()
.then((result) => {
@@ -1816,6 +2166,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
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) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",
@@ -2006,6 +2365,9 @@ function createBackgroundApplication(overrides = {}) {
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...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 requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
@@ -2013,8 +2375,17 @@ function createBackgroundApplication(overrides = {}) {
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
const fusionSrDetails = createFusionSrDetails({
cookieChanges: runtimeApi?.cookies?.onChanged,
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
wait: (...args) => platformBackgroundApi.wait(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
});
function start() {
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ prepareSrSession: sender => fusionSrDetails.prepareSession(sender), requestSrLookupWithSession: (...args) => fusionSrDetails.requestLookupWithSession(...args), messageTypes, requestSrLookupLanguage: () => fusionSrDetails.requestSrLookupLanguage(), requestSrLookupTechPlatform: () => fusionSrDetails.requestSrLookupTechPlatform(), requestSrLookupOpportunityBusiness: message => fusionSrDetails.requestSrLookupOpportunityBusiness(message), requestSrLookupPrimaryPillar: () => fusionSrDetails.requestSrLookupPrimaryPillar(), requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

1669
dist/firefox/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -9,7 +9,8 @@
],
"host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*"
"https://spa.oracle.com/*",
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
],
"content_scripts": [
{

View File

@@ -21,7 +21,8 @@ const baseManifest = {
],
host_permissions: [
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*"
"https://spa.oracle.com/*",
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
],
content_scripts: [
{

View File

@@ -16,6 +16,7 @@ module.exports = {
"src/features/opportunities/dashboard-state.js",
"src/features/debug/state.js",
"src/features/opportunities/columns.js",
"src/features/opportunities/badge-content.js",
"src/content/fusion-page.js",
"src/content/modal.js",
"src/content/theme.js",
@@ -32,8 +33,19 @@ module.exports = {
"src/services/fusion/opportunities.js",
"src/services/fusion/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-form.js",
"src/features/opportunities/searchable-select.js",
"src/features/opportunities/internal-sr-session.js",
"src/features/opportunities/sr-session-preparation.js",
"src/features/opportunities/workload-actions.js",
"src/features/opportunities/workload-action-form.js",
"src/features/opportunities/workload-action-options.js",
"src/features/opportunities/fiscal-periods.js",
"src/features/opportunities/table.js",
"src/features/opportunities/dashboard.js",
@@ -77,6 +89,8 @@ module.exports = {
"src/services/spa/install-base.js",
"src/services/spa/customer-service-requests.js",
"src/services/spa/workload-actions.js",
"src/services/spa/action-options.js",
"src/services/fusion/sr-details.js",
"src/services/spa/current-user.js",
"src/services/spa/consumption.js",
"src/services/spa/response-shapes.js",

View File

@@ -103,14 +103,22 @@ function createAuthConsumerSession({
const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE"
&& 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];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) {
if (needsSessionRecovery) {
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;
}
}
@@ -138,7 +146,7 @@ function createAuthConsumerSession({
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) {
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);
}

View File

@@ -1,11 +1,13 @@
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
let pending = null;
let allowed = false;
let userId = "";
function ensure() {
if (!pending) {
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
appendBackgroundRequestLog(response?.debugRequests);
const roles = response?.user?.roles;
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : "";
allowed = response?.ok === true && Array.isArray(roles)
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
return allowed;
@@ -13,5 +15,5 @@ function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, mes
}
return pending;
}
return { ensure, allowsCrmActivities: () => allowed };
return { ensure, allowsCrmActivities: () => allowed, getUserId: () => userId };
}

View File

@@ -115,6 +115,9 @@ function createBackgroundApplication(overrides = {}) {
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...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 requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
@@ -122,8 +125,17 @@ function createBackgroundApplication(overrides = {}) {
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
const fusionSrDetails = createFusionSrDetails({
cookieChanges: runtimeApi?.cookies?.onChanged,
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
wait: (...args) => platformBackgroundApi.wait(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
});
function start() {
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ prepareSrSession: sender => fusionSrDetails.prepareSession(sender), requestSrLookupWithSession: (...args) => fusionSrDetails.requestLookupWithSession(...args), messageTypes, requestSrLookupLanguage: () => fusionSrDetails.requestSrLookupLanguage(), requestSrLookupTechPlatform: () => fusionSrDetails.requestSrLookupTechPlatform(), requestSrLookupOpportunityBusiness: message => fusionSrDetails.requestSrLookupOpportunityBusiness(message), requestSrLookupPrimaryPillar: () => fusionSrDetails.requestSrLookupPrimaryPillar(), requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

View File

@@ -1,12 +1,23 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
invalidateProductsCache,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestInternalServiceRequest,
requestSrLookupPrimaryPillar,
requestSrLookupWithSession,
prepareSrSession,
requestSrLookupLanguage,
requestSrLookupTechPlatform,
requestSrLookupOpportunityBusiness,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
@@ -19,7 +30,27 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
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.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) {
getXsrfTokenCookie()
.then((result) => {
@@ -97,6 +128,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
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) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",

View File

@@ -24,12 +24,14 @@ function createContentApplication(overrides = {}) {
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
});
const contracts = createContentContracts();
const searchableSelect = createSearchableSelect({ environment });
const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState();
const opportunitiesTableState = createOpportunitiesTableState();
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
const preferencesState = createPreferencesState(contracts);
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
const productsContentState = createProductsContentState();
const aiSummaryState = createAiSummaryState();
const consumptionPlansState = createConsumptionPlansState();
@@ -54,6 +56,10 @@ function createContentApplication(overrides = {}) {
environment
});
const contentModal = createContentModal({
prepareSrSession: () => srSessionPreparation.ensure(),
closeActionForm: (...args) => crmSrForm.close(...args) || workloadActionForm.close(...args),
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
closeCrmSrPopover: () => crmSrPopover.close(),
ensureCurrentUser: () => currentUser.ensure(),
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS: contracts.AUTH_STATUS,
@@ -88,6 +94,7 @@ function createContentApplication(overrides = {}) {
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
createRefreshAllButton: (...args) => refreshAll.createButton(...args),
debugState,
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
@@ -110,10 +117,6 @@ function createContentApplication(overrides = {}) {
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...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),
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
@@ -189,7 +192,6 @@ function createContentApplication(overrides = {}) {
productsContentState,
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
@@ -216,12 +218,46 @@ function createContentApplication(overrides = {}) {
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
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({
requestDetails: srId => internalSrSession.request(srId),
MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
});
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
async function requestSrFormLookup(type, fields = {}) {
const result = await platformContentMessages.sendRuntimeMessage({ type, ...fields });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Request options.");
return result.items;
}
const crmSrForm = createCrmSrForm({
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({
createSrButton: number => crmSrForm.createButton(number),
closeSrForm: () => crmSrForm.close(false),
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
activityCache,
reportProgress: (...args) => activityProgress.update("sr", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
bindPopover: (...args) => crmSrPopover.bind(...args),
closePopover: (...args) => crmSrPopover.close(...args),
@@ -229,6 +265,7 @@ function createContentApplication(overrides = {}) {
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
});
const opportunitiesTable = createOpportunitiesTable({
renderActivityFilters: () => activityFilters.render(),
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
@@ -266,6 +303,7 @@ function createContentApplication(overrides = {}) {
environment
});
const opportunitiesFilters = createOpportunitiesFilters({
matchesActivities: item => activityFilters.matches(item),
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
@@ -329,8 +367,57 @@ function createContentApplication(overrides = {}) {
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
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({
loadWorkloadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
beginProductActivities: (...args) => productActivities.begin(...args),
completeProductActivities: (...args) => productActivities.complete(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
messageTypes,
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
@@ -342,7 +429,35 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
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({
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(),
authSessionState,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
@@ -352,6 +467,7 @@ function createContentApplication(overrides = {}) {
productsContentState, messageTypes, environment
});
const productsDetails = createProductsDetails({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
@@ -515,6 +631,8 @@ function createContentApplication(overrides = {}) {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
}
return {
workloadActionForm,
activityFilters,
start, contracts,
opportunityWorkloadActions,
fusionCrmServiceRequests, opportunityCrmServiceRequests,
@@ -552,6 +670,11 @@ function createContentApplication(overrides = {}) {
preferencesStorage,
preferencesView,
productsContentJobs,
productActivities,
activityProgress,
srSessionPreparation,
refreshAll,
activityCache,
productsDetails,
aiSummaryContent,
consumptionPlansAction,

View File

@@ -70,5 +70,20 @@ function createContentIcons({
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;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon };
}

View File

@@ -28,10 +28,13 @@ function createContentModal({
closeAccountPlansModal,
closeInstallBaseModal,
closeServiceRequestsModal,
closeActionForm,
closeCustomerFilterPanel,
closeOwnerFilterPanel,
closePreferencesModal,
createSettingsIcon,
createRefreshAllButton,
createActivityFilterControls,
debugState,
ensureExtensionStyles,
getCustomerOptions,
@@ -51,12 +54,10 @@ function createContentModal({
renderStageFilterButtons,
renderStatusFilterButtons,
requestOpportunitiesForPeriod,
loadCrmServiceRequests,
resetCrmServiceRequests,
resetWorkloadActions,
reopenWorkloadActions,
closeCrmSrPopover,
requestTokenRelayOnce,
ensureCurrentUser,
prepareSrSession,
resetCustomerAndOwnerFiltersForPeriod,
resetOpportunitiesTable,
restoreFilterPreferences,
@@ -83,15 +84,13 @@ function createContentModal({
if (existingModal) {
existingModal.hidden = false;
prepareSrSession?.();
existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
ensureCurrentUser().then(() => {
if (existingModal.hidden) return;
reopenWorkloadActions();
return requestTokenRelayOnce().then(token => {
if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion);
});
return requestTokenRelayOnce();
})
.then(() => renderOpportunitiesTable()).catch(() => {});
return;
@@ -114,7 +113,6 @@ function createContentModal({
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle");
const shell = document.createElement("section");
shell.className = "opportunities-extension-shell";
@@ -126,10 +124,6 @@ function createContentModal({
title.id = "opportunities-extension-title";
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");
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
@@ -149,9 +143,10 @@ function createContentModal({
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
productsCacheTimestamp.setAttribute("role", "status");
productsCacheTimestamp.hidden = true;
subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp);
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
const titleBlock = document.createElement("div");
titleBlock.className = "opportunities-extension-title-row";
titleBlock.append(title, subtitleRow);
const closeButton = document.createElement("button");
@@ -180,7 +175,7 @@ function createContentModal({
const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, preferencesButton, closeButton);
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
header.append(titleBlock, headerActions);
@@ -389,7 +384,8 @@ function createContentModal({
statusFilter.append(statusFilterLabel, statusFilters);
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);
const stageDashboard = document.createElement("section");
@@ -426,6 +422,7 @@ function createContentModal({
renderOwnerFilter();
renderOpportunitiesTable();
requestOpportunitiesForPeriod(select.value);
prepareSrSession?.();
select.focus();
}
@@ -469,8 +466,7 @@ function createContentModal({
function closeOpportunitiesModal() {
resetAuthCompletion();
resetWorkloadActions(false);
resetCrmServiceRequests();
closeCrmSrPopover();
const modal = document.getElementById(MODAL_ID);
if (modal) {
@@ -478,6 +474,7 @@ function createContentModal({
closeAccountPlansModal();
closeInstallBaseModal();
closeServiceRequestsModal();
closeActionForm(false);
modal.hidden = true;
}
@@ -506,6 +503,10 @@ function createContentModal({
}
if (event.key === "Escape") {
if (closeActionForm()) {
event.preventDefault();
return;
}
if (closeServiceRequestsModal()) {
event.preventDefault();
return;

View File

@@ -39,7 +39,10 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
current.promise = (async () => {
try {
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);
if (!section.isConnected || states.get(section) !== current) return;
current.status = "ready";
@@ -59,5 +62,5 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
})();
return current.promise;
}
return { createPlanDetailSection, loadPlanDetailForPanel };
return { createPlanDetailSection, loadPlanDetailForPanel, resetRequests: () => pending.clear() };
}

View File

@@ -31,7 +31,8 @@ function createConsumptionPlansAction({
return;
}
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
const currentRequests = consumptionPlansState.accountPlansRequestState;
currentRequests.set(optyNumber, { status: "loading" });
openAccountPlansModal(opportunity, [], "loading");
renderOpportunitiesTable();
requestConsumerTokenServiceOnce()
@@ -41,6 +42,7 @@ function createConsumptionPlansAction({
authorizations
}))
.then((response) => {
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
@@ -54,6 +56,7 @@ function createConsumptionPlansAction({
renderOpportunitiesTable();
openAccountPlansModal(opportunity, response.plans || [], "ready");
}).catch((error) => {
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
const errorMessage = error.message || "Unable to load cloud consumption.";
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
status: "error",

View File

@@ -23,10 +23,10 @@ function createDebugContentView({
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
if (entry.name === "requestOpportunities") return "opportunities";
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 === "requestInstallBase") return "installBase";
if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness"].includes(entry.name)) return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other";
}
@@ -222,7 +222,14 @@ function createDebugContentView({
method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET";
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);

View File

@@ -214,3 +214,10 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
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; }

View File

@@ -39,7 +39,7 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
try {
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
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);
}
view.render(await pending.get(partyNumber));
@@ -49,5 +49,5 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
});
return button;
}
return { createInstallBaseButton };
return { createInstallBaseButton, resetRequests: () => pending.clear() };
}

View 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 };
}

View 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 };
}

View 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" : "Workload action requests completed";
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 };
}

View 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 };
}

View File

@@ -13,7 +13,6 @@ function createOpportunitiesController({
productsContentState,
renderProductsProgress,
requestOpportunitiesPage,
loadCrmServiceRequests,
resetCrmServiceRequests,
resetWorkloadActions,
requestOpptyProductsInBackground,
@@ -70,7 +69,6 @@ function createOpportunitiesController({
failed: 0,
cacheHits: 0
};
productsContentState.productsLastCachedAt = 0;
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
@@ -84,7 +82,6 @@ function createOpportunitiesController({
}
try {
await loadCrmServiceRequests(token, requestVersion);
if (!isCurrentPeriodRequest(requestVersion)) return;
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);

View File

@@ -1,20 +1,38 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, environment }) {
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, environment }) {
let status = "idle";
let byOpportunity = new Map();
let pending = null;
let generation = 0;
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
function load(token, version) {
let loadedItems = [];
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 (!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;
}
async function run(token, version, currentGeneration) {
async function run(token, version, currentGeneration, cacheOptions) {
const isActive = () => currentGeneration === generation;
status = "loading";
try {
const items = await requestCrmServiceRequest(token, version, isActive);
if (!items || !isActive()) return;
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
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();
for (const item of items) {
const number = item?.OpportunityName_Id_c?.OptyNumber;
@@ -24,18 +42,38 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
grouped.get(key).push(item);
}
byOpportunity = grouped;
loadedItems = items;
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
return true;
} 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) {
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
badge.className = "opportunities-extension-crm-sr-badge";
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 descriptions = items.map(item => [
`Title: ${display(item.Title)}`,
@@ -49,10 +87,11 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
: !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");
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);
else badge.title = description;
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
badge.tabIndex = 0;
return badge;
}
return { reset, load, createBadge };
return { reset, load, createBadge, getSummary };
}

View File

@@ -0,0 +1,237 @@
function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportunity, 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;
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) {
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.addEventListener("submit", event => event.preventDefault());
const field = node("label", "opportunities-extension-action-form-field");
const select = node("select");
select.id = "opportunities-extension-sr-primary-pillar";
select.name = "primaryPillar";
field.htmlFor = select.id;
field.append(node("span", "", "Primary pillar"), select);
const autocomplete = searchableSelect(select, field);
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");
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(retry, 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;
wrapper.htmlFor = input.id;
wrapper.append(node("span", "", label), 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));
else {
const field = node("label", "opportunities-extension-action-form-field");
input = node("select");
input.id = `opportunities-extension-sr-${name}`;
input.name = name;
field.htmlFor = input.id;
field.append(node("span", "", label), input);
combo = searchableSelect(input, field);
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;
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 (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 = "";
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.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;
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 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;
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;
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;
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, cancel].filter(control => !control.disabled && !control.hidden);
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
});
dismiss.focus();
load();
extraLoads.forEach(loadOptions => loadOptions());
}
return { createButton, close, open };
}

View File

@@ -1,4 +1,4 @@
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, environment }) {
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, environment }) {
const { document, window, setTimeout, clearTimeout } = environment;
let active = null;
let sequence = 0;
@@ -74,6 +74,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close(true));
panel.append(dismiss);
const detailLoads = [];
items.forEach((item, index) => {
const section = document.createElement("section");
const heading = document.createElement("div");
@@ -94,6 +95,52 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
details.className = "opportunities-extension-sr-details";
details.textContent = descriptions[index];
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(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.addEventListener("pointerenter", () => clearTimeout(entry.timer));
@@ -101,6 +148,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
panel.addEventListener("focusout", focusOut);
host.append(panel);
active = entry;
detailLoads.forEach(load => load());
button.setAttribute("aria-expanded", "true");
entry.position();
document.addEventListener("pointerdown", entry.outside, true);

View File

@@ -6,7 +6,7 @@ function createOpportunitiesDashboard({
environment
}) {
const { document, requestAnimationFrame, performance } = environment;
function renderStageDashboard(items) {
function renderStageDashboard(items, totalCount = items.length) {
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
if (!dashboard) {
@@ -23,12 +23,12 @@ function createOpportunitiesDashboard({
totalCard.setAttribute("data-stage", "TOTAL");
totalLabel.textContent = "Total Opportunities";
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(
totalValue,
"TOTAL",
items.length,
(amount) => Math.round(amount).toLocaleString()
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
);
totalCard.append(totalLabel, totalValue);
dashboard.append(totalCard);

View File

@@ -24,6 +24,7 @@ function createOpportunitiesFilters({
renderOpportunitiesTable,
saveFilterPreferences,
stringifySearchValue,
matchesActivities,
environment
}) {
const { document } = environment;
@@ -335,12 +336,16 @@ function createOpportunitiesFilters({
function getSortedOpportunities() {
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) => {
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
return (allStages || opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c))
&& matchesSelectedStatus(item.StatusCode)
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
&& matchesTableSearch(item, normalizedSearch);
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
&& matchesTableSearch(item, normalizedSearch)
&& matchesActivities(item);
});
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
@@ -391,6 +396,7 @@ function createOpportunitiesFilters({
}
function matchesSelectedStatus(statusCode) {
if (STATUS_OPTIONS.every(status => opportunitiesFilterState.selectedStatuses.has(status))) return true;
if (statusCode === "WON_PENDING") {
return opportunitiesFilterState.selectedStatuses.has("WON");
}

View File

@@ -79,7 +79,7 @@ function createOpportunitiesFormatters({
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
const key = getOwnerKey(item);
const label = getOwnerName(item);
const label = String(getOwnerName(item)).trim() || "-";
if (key && label && !optionsByKey.has(key)) {
optionsByKey.set(key, { key, label: String(label) });
@@ -96,7 +96,9 @@ function createOpportunitiesFormatters({
function getOwnerKey(item) {
const owner = item.Owner;
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 !== "") {
return `party:${partyId}`;

View File

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

View File

@@ -0,0 +1,42 @@
function createProductActivities({ productsContentState, opportunitiesQueryState,
loadCrm, loadActions, blockActions, render }) {
let cycle = null;
function begin(requestId, token) {
cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false };
}
function complete(requestId) {
const current = cycle;
if (!current || current.completed || current.requestId !== requestId
|| productsContentState.currentProductsRequestId !== requestId) return;
current.completed = true;
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
if (!products.length) return;
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
&& opportunitiesQueryState.periodRequestVersion === current.version;
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => {
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: result.fromCache === true }));
}
await Promise.allSettled(pending);
if (!isActive()) return;
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
render();
}).catch(() => { if (isActive()) { products.forEach(blockActions); render(); } });
}
return { begin, complete };
}

View 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 };
}

View File

@@ -0,0 +1,113 @@
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.value}`).includes(normalize(query)));
index = -1;
list.textContent = "";
matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent);
row.id = `${list.id}-${i}`;
row.setAttribute("role", "option");
row.setAttribute("aria-selected", "false");
row.addEventListener("mousedown", event => event.preventDefault());
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
list.append(row);
});
if (!matches.length) {
const empty = element("div", "opportunities-extension-action-option", "No results");
empty.setAttribute("role", "status");
list.append(empty);
}
positionList();
list.hidden = false;
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-activedescendant", "");
}
input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value));
input.addEventListener("blur", sync);
input.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (list.hidden) render();
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
highlight();
} else if (event.key === "Enter" && !list.hidden) {
event.preventDefault();
choose(index < 0 && matches.length === 1 ? 0 : index);
} else if (event.key === "Escape" && !list.hidden) {
event.preventDefault();
event.stopPropagation();
sync();
} else if (event.key === "Tab") sync();
});
sync();
return { input, sync };
}
return searchableSelect;
}

View 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 };
}

View File

@@ -9,14 +9,7 @@
.opportunities-extension-auth-badge { transition: none; }
}
.opportunities-extension-crm-sr-badge {
flex: 0 0 auto;
padding: 0 6px;
border: 1px solid #408254;
border-radius: 8px;
font-size: 10px;
line-height: 18px;
font-weight: 600;
white-space: nowrap;
color: #185b30;
background: #e1f1e5;
}
@@ -31,14 +24,15 @@
.opportunities-extension-form {
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;
gap: 12px;
min-height: 0;
padding: 12px;
}
.opportunities-extension-form > * {
.opportunities-extension-form > *,
.opportunities-extension-badge-filters > section {
min-width: 0;
width: 100%;
max-width: none;
@@ -47,7 +41,8 @@
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;
line-height: 16px;
}
@@ -89,30 +84,77 @@
}
.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;
padding: 4px 7px;
border: 1px solid currentColor;
border-radius: 18px;
font-family: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
line-height: 18px;
white-space: nowrap;
}
/* Native select intrinsic sizing includes its longest option, not just the selection. */
@media (min-width: 1151px) {
.opportunities-extension-form > .opportunities-extension-field,
.opportunities-extension-form .opportunities-extension-select-wrap,
.opportunities-extension-form .opportunities-extension-field select {
width: max-content;
max-width: none;
}
.opportunities-extension-cell-actions > .opportunities-extension-products-count,
.opportunities-extension-cell-actions > .opportunities-extension-products-ramp,
.opportunities-extension-cell-actions > .opportunities-extension-crm-sr-badge {
display: inline-flex;
align-items: center;
justify-content: flex-start;
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) {
.opportunities-extension-form {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.opportunities-extension-form > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter {
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
grid-column: span 2;
}
}
@@ -125,16 +167,242 @@
.opportunities-extension-form .opportunities-extension-status-filter-controls {
flex-wrap: wrap;
}
.opportunities-extension-form > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter {
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
grid-template-rows: auto auto;
}
}
@media (max-width: 420px) {
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
.opportunities-extension-form > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter { grid-column: auto; }
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.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-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. */
@@ -232,18 +500,11 @@
}
.opportunities-extension-products-ramp {
display: inline-block;
min-width: 0;
max-width: 100%;
padding: 0 6px;
border: 1px solid #bba5cf;
border-radius: 8px;
background: #f0e8f7;
color: #63358a;
font-size: 10px;
font-weight: 600;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -302,6 +563,11 @@
.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-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 {
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
display: grid; place-items: center; padding: 4px; border: 0;
@@ -311,3 +577,41 @@
.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 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; }
}

View File

@@ -23,6 +23,7 @@ function createOpportunitiesTable({
productsContentState,
renderCustomerFilter,
renderOwnerFilter,
renderActivityFilters,
renderStageDashboard,
resetCustomerFilter,
resetOwnerFilter,
@@ -47,6 +48,7 @@ function createOpportunitiesTable({
}
function renderOpportunitiesTable() {
renderActivityFilters();
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
if (!table) {
@@ -99,7 +101,7 @@ function createOpportunitiesTable({
const tableBody = document.createElement("tbody");
const items = getSortedOpportunities();
renderStageDashboard(items);
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
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");

View 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 };
}

View 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)
};
}

View File

@@ -1,31 +1,58 @@
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, createActionButton, closeActionForm, setBadgeContent, environment }) {
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) {
closeActionForm(false);
session.enabled = false;
for (const job of session.queue) job.record.resolveCompletion();
session.queue.length = 0;
session = newSession();
session.enabled = enabled;
reportProgress("idle");
}
function idsFor(products) {
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;
const pending = [];
if ([...session.progress].every(record => record.status !== "loading")) session.progress.clear();
for (const revnId of idsFor(products)) {
if (session.records.has(revnId)) continue;
const record = { status: "loading", actions: [], hasNext: false };
session.records.set(revnId, record);
session.queue.push({ revnId, record });
if (!refresh && session.records.has(revnId)) {
pending.push(session.records.get(revnId).completion);
continue;
}
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);
return Promise.all(pending);
}
function reopen() {
reset();
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
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." });
}
}
function pump(current) {
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
const job = current.queue.shift();
@@ -33,26 +60,48 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
run(current, job).finally(() => { current.running -= 1; pump(current); });
}
}
async function run(current, { revnId, record }) {
async function run(current, { revnId, record, cacheOptions }) {
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();
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
const 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;
result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
appendBackgroundRequestLog(result?.debugRequests);
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.hasNext = result.hasNext;
record.status = "ready";
if (cacheOptions) await activityCache.write("actions", revnId, cacheOptions.cachedAt, result.actions, result.hasNext);
} catch (error) {
if (!current.enabled || current !== session) return;
record.status = "error";
record.error = error.message || "Unable to load workload actions.";
} 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;
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
if (!products) return null;
@@ -63,12 +112,47 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|| records.some(record => record?.status === "error" || record?.hasNext);
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";
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");
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
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 => [
`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"}`,
@@ -81,7 +165,8 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
badge.tabIndex = 0;
badge.setAttribute("aria-label", badge.title);
if (state === "missing") badge.append(createActionButton(String(optyNumber)));
return badge;
}
return { reset, reopen, loadProducts, createBadge };
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary, applySavedAction };
}

View File

@@ -5,8 +5,17 @@ function createProductsCache({
storageLocalGet,
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 = () => {}) {
const currentGeneration = generation;
const cache = await getProductsCache();
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
const cachedEntry = cache[optyNumber];
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
@@ -24,15 +33,14 @@ function createProductsCache({
})
.then(async (items) => {
const fetchedAt = Date.now();
cache[optyNumber] = {
fetchedAt,
items
};
if (generation === currentGeneration) {
cache[optyNumber] = { fetchedAt, items };
await saveProductsCache(cache).catch(() => {});
}
return { items, fetchedAt };
})
.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 { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}

View File

@@ -7,7 +7,8 @@ function createProductsContentJobs({
preferencesState,
productsContentState,
renderOpportunitiesTable,
loadWorkloadActions,
beginProductActivities,
completeProductActivities,
sendRuntimeMessage,
environment
}) {
@@ -52,6 +53,7 @@ function createProductsContentJobs({
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
productsContentState.currentProductsRequestId = requestId;
beginProductActivities(requestId, token);
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
@@ -119,11 +121,6 @@ function createProductsContentJobs({
cachedAt: Number(message.cachedAt) || 0,
error: message.error || ""
});
loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber)));
}
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
productsContentState.productsLastCachedAt = Number(message.cachedAt);
}
productsContentState.productsProgress = {
@@ -134,6 +131,7 @@ function createProductsContentJobs({
cacheHits: Number(message.cacheHits) || 0
};
renderProductsProgress();
if (message.state === "complete" || message.state === "complete-with-errors") completeProductActivities(message.requestId);
queueProductsTableRender();
return false;
});

View File

@@ -1,5 +1,6 @@
function createProductsDetails({
OPPORTUNITIES_COLUMNS,
setBadgeContent,
createAiSummaryAlert,
formatOracleResponseDate,
productsContentState,
@@ -17,7 +18,7 @@ function createProductsDetails({
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp";
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.setAttribute("aria-label", badge.title);
return badge;
@@ -31,7 +32,7 @@ function createProductsDetails({
button.type = "button";
button.className = "opportunities-extension-products-count";
button.textContent = String(count);
setBadgeContent(button, "products", count);
button.setAttribute("aria-expanded", String(isExpanded));
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;

View File

@@ -17,7 +17,10 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
try {
const planNumber = String(plan.subPlanNum ?? "").trim();
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
if (!pending.has(planNumber)) pending.set(planNumber, request(planNumber).finally(() => pending.delete(planNumber)));
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));
} catch (error) {
handle.showError(error.message || "Unable to load rate card.");
@@ -34,5 +37,5 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
button.addEventListener("click", () => showRatecard(plan, button));
return button;
}
return { createRatecardButton, showRatecard };
return { createRatecardButton, showRatecard, resetRequests: () => pending.clear() };
}

View File

@@ -39,7 +39,8 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
try {
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
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));
} catch (error) {
@@ -48,5 +49,5 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
});
return button;
}
return { createServiceRequestsButton };
return { createServiceRequestsButton, resetRequests: () => pending.clear() };
}

View File

@@ -18,9 +18,10 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
"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 = [];
let offset = 0;
let attempt = 1;
while (isActive()) {
const payload = createPayload(offset);
const headers = {
@@ -28,30 +29,33 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
};
const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url,
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset } });
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset, attempt } });
try {
const response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
});
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();
if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") {
throw new Error("CRM service request query returned an invalid response.");
}
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 current = data.offset === undefined ? offset : Number(data.offset);
if (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|| current !== offset || current + received <= offset) {
if (data.hasMore && (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|| current !== offset || current + received <= offset)) {
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;
attempt = 1;
} catch (error) {
logRequestFailure(entry, error.message || "CRM service request query failed.");
throw error;
if (attempt === 5) throw error;
attempt += 1;
}
}
return null;

View File

@@ -0,0 +1,202 @@
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; }
}
return { requestLookupWithSession, requestInternalServiceRequest, requestSrLookupOpportunityBusiness,
prepareSession: sender => requestLookupWithSession(null, {}, sender, true),
requestSrLookupPrimaryPillar: () => requestLookup("SENGG_PRIMARY_PILLAR", "requestSrLookupPrimaryPillar"),
requestSrLookupLanguage: () => requestLookup("SENGG_LANGUAGE", "requestSrLookupLanguage"),
requestSrLookupTechPlatform: () => requestLookup("SENGG_TECH_PLATFORM", "requestSrLookupTechPlatform") };
}

View File

@@ -4,7 +4,7 @@ function createHttpBackgroundJson({
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 debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
let response;
@@ -25,7 +25,10 @@ function createHttpBackgroundJson({
appendBackgroundDebugEntry(debugRequests, debugEntry);
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) {
@@ -33,6 +36,7 @@ function createHttpBackgroundJson({
}
try {
if (allowEmpty && !responsePayload.text.trim()) return null;
return parseJsonResponse(responsePayload.text, requestName);
} catch (error) {
error.httpStatus = response.status;

View 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 };
}

View File

@@ -11,7 +11,17 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
requestSrLookupPrimaryPillar: "opportunitiesExtension.requestSrLookupPrimaryPillar",
prepareSrSession: "opportunitiesExtension.prepareSrSession",
requestSrLookupLanguage: "opportunitiesExtension.requestSrLookupLanguage",
requestSrLookupTechPlatform: "opportunitiesExtension.requestSrLookupTechPlatform",
requestSrLookupOpportunityBusiness: "opportunitiesExtension.requestSrLookupOpportunityBusiness",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"

View 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);
});

View File

@@ -0,0 +1,144 @@
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 };
}
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, {});
});

View 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;/);
});

View 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]);
});

View 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);
});

View File

@@ -29,6 +29,7 @@ function setup(mockSalesToken = true) {
const context = {
document: {
createElement: (tag) => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
querySelectorAll: () => visibleAlerts
},
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),

View 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);
});

113
tests/badgeIcons.test.cjs Normal file
View 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;/);
});

View File

@@ -41,7 +41,7 @@ test("distribution contracts: manifests, execution boundaries and deterministic
manifest_version: 3, name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.2", permissions: ["cookies", "storage"],
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: [{
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"

View 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);
});

View File

@@ -3,7 +3,8 @@ const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const response = data => new Response(JSON.stringify(data), { status: 200 });
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 });
app.currentUser.allowsCrmActivities = () => true;
app.debugContentView.renderDebugPanel = () => {};
@@ -14,6 +15,19 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
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("CRM fetch is called without the environment object as its receiver", async () => {
let calls = 0;
const app = setup(async function (url, options) {
@@ -28,7 +42,7 @@ test("CRM fetch is called without the environment object as its receiver", async
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 app = setup(async (url, options) => {
const payload = JSON.parse(options.body);
@@ -40,11 +54,12 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
app.productsContentJobs.renderProductsProgress = () => {};
app.productsContentJobs.requestOpptyProductsInBackground = () => {};
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" }];
};
await app.opportunitiesController.requestOpportunities("synthetic-token", "Current Quarter", {}, 1);
assert.equal(states.at(-1).status, "ready");
await app.opportunityCrmServiceRequests.load("synthetic-token", 1);
assert.deepEqual(calls.map(call => call.payload.offset), [0, 1]);
const { payload, options, url } = calls[0];
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries");
@@ -79,11 +94,11 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
assert.equal(app.debugContentView.getRequestCategory(log), "serviceRequests");
}
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");
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.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
app.opportunityCrmServiceRequests.reset();
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST-1"), null);
@@ -107,7 +122,8 @@ test("CRM failures do not mislabel missing SRs or block the opportunity list", a
app.fusionOpportunities.requestOpportunitiesPage = async () => { loaded = true; return []; };
await app.opportunitiesController.requestOpportunities("token", "Current Quarter", {}, 1);
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");
}
});
@@ -122,8 +138,8 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
await app.opportunityCrmServiceRequests.load("token", 2);
resolveOld(response({ items: [sr("OLD")], hasMore: true, count: 1, offset: 0 }));
await old;
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "\u2705 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "\u26d4 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
assert.equal(app.debugState.requestLog.length, 1);
});
@@ -144,8 +160,8 @@ test("Reference changes share pending pagination and reuse completed CRM results
app.opportunitiesQueryState.periodRequestVersion = 3;
await app.opportunityCrmServiceRequests.load("token", 3);
assert.equal(calls, 2);
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "\u2705 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "\u2705 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "0h");
app.opportunityCrmServiceRequests.reset();
await app.opportunityCrmServiceRequests.load("token", 3);
assert.equal(calls, 3);

300
tests/crmSrDetails.test.cjs Normal file
View File

@@ -0,0 +1,300 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const type = "opportunitiesExtension.requestInternalServiceRequest";
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);
});

View File

@@ -1,6 +1,31 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
test("internal SR uses Session without TokenRelay 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() {
const document = { activeElement: null, listeners: new Map() };
@@ -104,3 +129,42 @@ test("SR popover pins on click, closes on Escape/outside/scroll/reset and remove
assert.equal(panel(), undefined);
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" }] });
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 === "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"));
});

View File

@@ -73,10 +73,12 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
assert.equal(same, app.currentUser.ensure());
finish({ ok: true, user: { roles } });
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" }] };
app.productsContentState.opptyProductsByNumber.set("OP", products);
app.opportunityWorkloadActions.loadProducts(products);
app.productActivities.complete("products-cycle");
await new Promise(resolve => setImmediate(resolve));
assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), allowed);
if (!allowed) {

View 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");
});

View File

@@ -20,6 +20,19 @@ function fixture() {
return { ...dom, app, add, nodes, tab, ids, select };
}
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("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
const h = fixture();
const background = loadApplication("background");

View File

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

View File

@@ -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.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].title, "1 product");
assert.equal(customer.children[0].textContent, "Synthetic customer");
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
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);
});
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");
assert.match(css, /grid-template-columns: max-content minmax\(110px, 1fr\) minmax\(110px, 1fr\) minmax\(145px, 180px\) 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\)\) max-content 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\)/);
});
@@ -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");
assert.equal(badge(), undefined);
for (const [values, expected] of [
[[], null], [[null], "RM N/D"], [[null, undefined, "", " "], "RM N/D"], [[6], "RM 6"],
[[0], "RM 0"], [[0, "3", null, " 6 ", 3], "RM 0,3,N/D,6,3"]
[[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
[[0], "0"], [[0, "3", null, " 6 ", 3], "0,3,N/D,6,3"]
]) {
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
@@ -92,10 +94,13 @@ test("ramp months preserve zero and flag missing values beside the product count
else {
assert.equal(badge().textContent, expected);
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D")));
assert.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(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");
});
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 () => {
const h = setup();
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);
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.opportunitiesTable.renderOpportunitiesTable();
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();
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
});
@@ -210,7 +242,8 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.match(css, /flex: 0 0 24px/);
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "");
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "")
.replace(/[^{}]*\.opportunities-extension-title-row[^{}]*\{[^}]*\}/g, "");
assert.doesNotMatch(tableRules, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
assert.match(css, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);

View File

@@ -0,0 +1,187 @@
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") {
app.productsContentJobs.requestOpptyProductsInBackground([{ OptyNumber: "A" }], 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 products restore activities without network; mixed cycles fetch only fresh products", async () => {
const h = setup();
const cached = h.start();
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
h.progress(cached, { state: "complete" });
await flush();
assert.deepEqual(h.calls, []);
const mixed = h.start();
h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
h.progress(mixed, { state: "complete" });
await flush();
assert.deepEqual(h.calls, ["SR token", "fresh"]);
});
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
View 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");
});

View 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"]] : []);
}
});

View File

@@ -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 = {}, cookies = () => [], storageError = false, messageTimeout = 2000 } = {}) {
const time = clock();
const listeners = [];
const progress = [];
@@ -123,7 +123,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
function dispatch(message) {
return new Promise((resolve, reject) => {
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 => {
clearTimeout(timer);
assert.equal(++replies, 1, "Message must receive exactly one response");

View File

@@ -0,0 +1,498 @@
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("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);
});

View File

@@ -52,7 +52,9 @@ test("workload actions reject missing credentials, invalid ids, HTTP/network/JSO
});
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.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
app.authSessionState.principal = "test@example.invalid";
@@ -62,6 +64,85 @@ function contentFixture(send) {
return app;
}
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 () => {
const calls = [];
const app = contentFixture(async message => {
@@ -71,18 +152,18 @@ test("actions aggregate all products, share RevnId queries across Reference chan
const products = { items: [{ RevnId: "1" }, { RevnId: 2 }, { RevnId: "1" }] };
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ...");
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "...");
await flush();
assert.equal(calls.length, 2);
assert.equal(calls[0].authorization.value, "synthetic-wwb");
const badge = app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge.textContent, "\u2705 Act");
assert.equal(badge.textContent, "1");
assert.match(badge.title, /<script>safe<\/script>/);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(calls.length, 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.loadProducts(products);
await flush();
@@ -100,9 +181,9 @@ test("errors, missing ids and partial empty pages never appear as confirmed abse
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
app.productsContentState.opptyProductsByNumber.set("A", { items: [{}] });
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
}
});
@@ -117,15 +198,17 @@ test("Act requires an owner matching tokenrelay principal while tooltip retains
app.opportunityWorkloadActions.loadProducts(products);
await flush();
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, /My action/);
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, /My action/);
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 () => {
@@ -158,7 +241,7 @@ test("actions queue caps concurrency and discards results/queued jobs from close
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;
const calls = [];
const app = loadApplication("content", {
@@ -173,14 +256,19 @@ test("product progress triggers Actions for cached products and ignores stale pr
return { ok: true, actions: [], hasNext: false };
};
app.productsContentState.currentProductsRequestId = "current";
app.productActivities.begin("current", "synthetic");
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [];
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
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" });
await flush();
assert.equal(calls.length, 0);
listener(message);
await flush();
assert.equal(calls.length, 0);
listener({ ...message, optyNumber: undefined, state: "complete" });
await flush();
assert.equal(calls.length, 1);
assert.equal(calls[0].type, "opportunitiesExtension.requestWlActions");
assert.equal(calls[0].revnId, "123");