Badge indicator showing whether an opportunity has an associated Workload Workbench Action

This commit is contained in:
2026-09-24 17:00:32 -03:00
parent f9595b94e0
commit f74071e39e
30 changed files with 1625 additions and 68 deletions

View File

@@ -34,6 +34,20 @@ in memory only. Reference changes share the in-flight lookup and reuse its resul
(including failures) without another request. Closing resets this state; reopening (including failures) without another request. Closing resets this state; reopening
starts a fresh lookup, and responses from previous openings are ignored. starts a fresh lookup, and responses from previous openings are ignored.
## Workload Actions
Workload Actions are queried in the background after product results arrive,
including cached products. 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.
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.
## Build ## Build
Use Node.js: Use Node.js:

View File

@@ -13,6 +13,7 @@ function createMessageTypes() {
requestAccountPlans: "opportunitiesExtension.requestAccountPlans", requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase", requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail", requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan" requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
@@ -204,7 +205,8 @@ function createAuthConsumerSession({
accountSummary: accountSummaryAuthorization, accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization, accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""), installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "") customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", ""),
workloadActions: extractTokenServiceAuthorization(tokenResponse, "WWB-TS", "Wwb-Provider-Authorization")
}, },
debugRequests debugRequests
}; };
@@ -610,6 +612,37 @@ function createSpaCustomerServiceRequests({ ACCOUNT_SUMMARY_URL, fetchJson, find
return { requestCustomerServiceRequest }; return { requestCustomerServiceRequest };
} }
// Source: src/services/spa/workload-actions.js
function createSpaWorkloadActions({ fetchJson }) {
async function requestWlActions(message) {
const debugRequests = [];
try {
const revnId = String(message.revnId ?? "").trim();
const token = message.authorization?.value;
if (!/^\d+$/.test(revnId)) throw new Error("A numeric RevnId is required.");
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const url = `https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions?parentType=WORKLOAD&parentId=${encodeURIComponent(revnId)}`;
const result = await fetchJson(url, {
method: "GET", credentials: "include",
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
}, "requestWlActions", debugRequests);
if (!result || !Array.isArray(result.actions) || typeof result.hasNext !== "boolean"
|| result.actions.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
throw new Error("requestWlActions returned an invalid collection.");
}
return { ok: true, actions: result.actions, hasNext: result.hasNext, 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 { requestWlActions };
}
// Source: src/services/spa/consumption.js // Source: src/services/spa/consumption.js
function createSpaConsumption({ function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS, CONSUMPTION_TREND_REQUESTS,
@@ -1662,6 +1695,7 @@ function registerBackgroundMessages({
requestAccountPlans, requestAccountPlans,
requestInstallBase, requestInstallBase,
requestCustomerServiceRequest, requestCustomerServiceRequest,
requestWlActions,
requestConsumerTokenService, requestConsumerTokenService,
requestConsumptionTrend, requestConsumptionTrend,
requestConsumptionPlanDetail, requestConsumptionPlanDetail,
@@ -1743,6 +1777,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; return true;
} }
if (message.type === messageTypes.requestWlActions) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestCustomerServiceRequest) { if (message.type === messageTypes.requestCustomerServiceRequest) {
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({ requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load service requests.", ok: false, error: error.message || "Unable to load service requests.",
@@ -1921,6 +1963,8 @@ function createBackgroundApplication(overrides = {}) {
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args); const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args); const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args); const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args); const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args); const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args); const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
@@ -1929,7 +1973,7 @@ function createBackgroundApplication(overrides = {}) {
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome; const runtimeApi = environment.browser || environment.chrome;
function start() { function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
} }
return { return {
start, contracts, start, contracts,

File diff suppressed because one or more lines are too long

View File

@@ -13,6 +13,7 @@ function createMessageTypes() {
requestAccountPlans: "opportunitiesExtension.requestAccountPlans", requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase", requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail", requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan" requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
@@ -204,7 +205,8 @@ function createAuthConsumerSession({
accountSummary: accountSummaryAuthorization, accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization, accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""), installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "") customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", ""),
workloadActions: extractTokenServiceAuthorization(tokenResponse, "WWB-TS", "Wwb-Provider-Authorization")
}, },
debugRequests debugRequests
}; };
@@ -610,6 +612,37 @@ function createSpaCustomerServiceRequests({ ACCOUNT_SUMMARY_URL, fetchJson, find
return { requestCustomerServiceRequest }; return { requestCustomerServiceRequest };
} }
// Source: src/services/spa/workload-actions.js
function createSpaWorkloadActions({ fetchJson }) {
async function requestWlActions(message) {
const debugRequests = [];
try {
const revnId = String(message.revnId ?? "").trim();
const token = message.authorization?.value;
if (!/^\d+$/.test(revnId)) throw new Error("A numeric RevnId is required.");
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const url = `https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions?parentType=WORKLOAD&parentId=${encodeURIComponent(revnId)}`;
const result = await fetchJson(url, {
method: "GET", credentials: "include",
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
}, "requestWlActions", debugRequests);
if (!result || !Array.isArray(result.actions) || typeof result.hasNext !== "boolean"
|| result.actions.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
throw new Error("requestWlActions returned an invalid collection.");
}
return { ok: true, actions: result.actions, hasNext: result.hasNext, 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 { requestWlActions };
}
// Source: src/services/spa/consumption.js // Source: src/services/spa/consumption.js
function createSpaConsumption({ function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS, CONSUMPTION_TREND_REQUESTS,
@@ -1662,6 +1695,7 @@ function registerBackgroundMessages({
requestAccountPlans, requestAccountPlans,
requestInstallBase, requestInstallBase,
requestCustomerServiceRequest, requestCustomerServiceRequest,
requestWlActions,
requestConsumerTokenService, requestConsumerTokenService,
requestConsumptionTrend, requestConsumptionTrend,
requestConsumptionPlanDetail, requestConsumptionPlanDetail,
@@ -1743,6 +1777,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; return true;
} }
if (message.type === messageTypes.requestWlActions) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestCustomerServiceRequest) { if (message.type === messageTypes.requestCustomerServiceRequest) {
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({ requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load service requests.", ok: false, error: error.message || "Unable to load service requests.",
@@ -1921,6 +1963,8 @@ function createBackgroundApplication(overrides = {}) {
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args); const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args); const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args); const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args); const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args); const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args); const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
@@ -1929,7 +1973,7 @@ function createBackgroundApplication(overrides = {}) {
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome; const runtimeApi = environment.browser || environment.chrome;
function start() { function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
} }
return { return {
start, contracts, start, contracts,

File diff suppressed because one or more lines are too long

View File

@@ -31,6 +31,8 @@ module.exports = {
"src/services/fusion/opportunities.js", "src/services/fusion/opportunities.js",
"src/services/fusion/crm-service-requests.js", "src/services/fusion/crm-service-requests.js",
"src/features/opportunities/crm-service-requests.js", "src/features/opportunities/crm-service-requests.js",
"src/features/opportunities/crm-sr-popover.js",
"src/features/opportunities/workload-actions.js",
"src/features/opportunities/fiscal-periods.js", "src/features/opportunities/fiscal-periods.js",
"src/features/opportunities/table.js", "src/features/opportunities/table.js",
"src/features/opportunities/dashboard.js", "src/features/opportunities/dashboard.js",
@@ -73,6 +75,7 @@ module.exports = {
"src/services/spa/accounts.js", "src/services/spa/accounts.js",
"src/services/spa/install-base.js", "src/services/spa/install-base.js",
"src/services/spa/customer-service-requests.js", "src/services/spa/customer-service-requests.js",
"src/services/spa/workload-actions.js",
"src/services/spa/consumption.js", "src/services/spa/consumption.js",
"src/services/spa/response-shapes.js", "src/services/spa/response-shapes.js",
"src/services/http/background-json.js", "src/services/http/background-json.js",

View File

@@ -60,7 +60,8 @@ function createAuthConsumerSession({
accountSummary: accountSummaryAuthorization, accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization, accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""), installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "") customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", ""),
workloadActions: extractTokenServiceAuthorization(tokenResponse, "WWB-TS", "Wwb-Provider-Authorization")
}, },
debugRequests debugRequests
}; };

View File

@@ -20,6 +20,7 @@ function createAuthContentSession({
}) })
.catch((error) => { .catch((error) => {
authSessionState.accessToken = ""; authSessionState.accessToken = "";
authSessionState.principal = "";
authSessionState.authStatus = AUTH_STATUS.unauthenticated; authSessionState.authStatus = AUTH_STATUS.unauthenticated;
throw error; throw error;
}) })

View File

@@ -3,6 +3,7 @@ function createAuthSessionState({ AUTH_STATUS } = {}) {
tokenRelayRequest: null, tokenRelayRequest: null,
consumerTokenServiceRequest: null, consumerTokenServiceRequest: null,
accessToken: "", accessToken: "",
principal: "",
authStatus: AUTH_STATUS.idle authStatus: AUTH_STATUS.idle
}; };
} }

View File

@@ -1,5 +1,6 @@
function createAuthTokenRelay({ function createAuthTokenRelay({
TOKEN_RELAY_URL, TOKEN_RELAY_URL,
authSessionState,
createDebugBody, createDebugBody,
getXsrfToken, getXsrfToken,
logRequestFailure, logRequestFailure,
@@ -13,6 +14,7 @@ function createAuthTokenRelay({
}) { }) {
const { fetch } = environment; const { fetch } = environment;
async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) { async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) {
if (url === TOKEN_RELAY_URL) authSessionState.principal = "";
let refreshResult = null; let refreshResult = null;
let refreshAttempted = false; let refreshAttempted = false;
@@ -95,6 +97,9 @@ function createAuthTokenRelay({
access_token: maskToken(responseData.access_token) access_token: maskToken(responseData.access_token)
}, null, 2))); }, null, 2)));
if (url === TOKEN_RELAY_URL) {
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
}
if (includeExpiry) { if (includeExpiry) {
const lifetime = Number(responseData.expires_in); const lifetime = Number(responseData.expires_in);
return { return {

View File

@@ -111,6 +111,8 @@ function createBackgroundApplication(overrides = {}) {
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args); const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args); const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args); const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args); const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args); const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args); const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
@@ -119,7 +121,7 @@ function createBackgroundApplication(overrides = {}) {
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome; const runtimeApi = environment.browser || environment.chrome;
function start() { function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
} }
return { return {
start, contracts, start, contracts,

View File

@@ -6,6 +6,7 @@ function registerBackgroundMessages({
requestAccountPlans, requestAccountPlans,
requestInstallBase, requestInstallBase,
requestCustomerServiceRequest, requestCustomerServiceRequest,
requestWlActions,
requestConsumerTokenService, requestConsumerTokenService,
requestConsumptionTrend, requestConsumptionTrend,
requestConsumptionPlanDetail, requestConsumptionPlanDetail,
@@ -87,6 +88,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; return true;
} }
if (message.type === messageTypes.requestWlActions) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestCustomerServiceRequest) { if (message.type === messageTypes.requestCustomerServiceRequest) {
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({ requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load service requests.", ok: false, error: error.message || "Unable to load service requests.",

View File

@@ -104,6 +104,8 @@ function createContentApplication(overrides = {}) {
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args), resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args), resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
reopenWorkloadActions: () => opportunityWorkloadActions.reopen(),
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args), loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(), resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args), restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
@@ -143,6 +145,7 @@ function createContentApplication(overrides = {}) {
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args) requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args)
}); });
const authTokenRelay = createAuthTokenRelay({ const authTokenRelay = createAuthTokenRelay({
authSessionState,
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL, TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
createDebugBody: (...args) => debugContentLog.createDebugBody(...args), createDebugBody: (...args) => debugContentLog.createDebugBody(...args),
getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args), getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args),
@@ -164,6 +167,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const opportunitiesController = createOpportunitiesController({ const opportunitiesController = createOpportunitiesController({
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
STAGE_OPTIONS: contracts.STAGE_OPTIONS, STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS, STATUS_OPTIONS: contracts.STATUS_OPTIONS,
authSessionState, authSessionState,
@@ -205,11 +209,20 @@ function createContentApplication(overrides = {}) {
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
maskToken: (...args) => debugContentLog.maskToken(...args), environment maskToken: (...args) => debugContentLog.maskToken(...args), environment
}); });
const crmSrPopover = createCrmSrPopover({
MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
});
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({ const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
bindPopover: (...args) => crmSrPopover.bind(...args),
closePopover: (...args) => crmSrPopover.close(...args),
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args), requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
}); });
const opportunitiesTable = createOpportunitiesTable({ const opportunitiesTable = createOpportunitiesTable({
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL, ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS: columns, OPPORTUNITIES_COLUMNS: columns,
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID, OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
@@ -309,6 +322,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const productsContentJobs = createProductsContentJobs({ const productsContentJobs = createProductsContentJobs({
loadWorkloadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
messageTypes, messageTypes,
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
@@ -320,6 +334,14 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment environment
}); });
const opportunityWorkloadActions = createOpportunityWorkloadActions({
authSessionState,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender(),
productsContentState, messageTypes, environment
});
const productsDetails = createProductsDetails({ const productsDetails = createProductsDetails({
OPPORTUNITIES_COLUMNS: columns, OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args), createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
@@ -485,6 +507,7 @@ function createContentApplication(overrides = {}) {
} }
return { return {
start, contracts, start, contracts,
opportunityWorkloadActions,
fusionCrmServiceRequests, opportunityCrmServiceRequests, fusionCrmServiceRequests, opportunityCrmServiceRequests,
authSessionState, authSessionState,
authSalesCommonState, authSalesCommonState,

View File

@@ -53,6 +53,8 @@ function createContentModal({
requestOpportunitiesForPeriod, requestOpportunitiesForPeriod,
loadCrmServiceRequests, loadCrmServiceRequests,
resetCrmServiceRequests, resetCrmServiceRequests,
resetWorkloadActions,
reopenWorkloadActions,
requestTokenRelayOnce, requestTokenRelayOnce,
resetCustomerAndOwnerFiltersForPeriod, resetCustomerAndOwnerFiltersForPeriod,
resetOpportunitiesTable, resetOpportunitiesTable,
@@ -63,7 +65,15 @@ function createContentModal({
toggleOwnerFilterPanel, toggleOwnerFilterPanel,
environment environment
}) { }) {
const { document, window } = environment; const { document, window, setTimeout, clearTimeout, matchMedia } = environment;
let authCompletion = null;
function resetAuthCompletion() {
if (!authCompletion) return;
clearTimeout(authCompletion.waitTimer);
clearTimeout(authCompletion.hideTimer);
authCompletion.badge.setAttribute("data-fading", "false");
authCompletion = null;
}
function openOpportunitiesModal() { function openOpportunitiesModal() {
ensureExtensionStyles(); ensureExtensionStyles();
debugState.debugSecretsVisible = false; debugState.debugSecretsVisible = false;
@@ -72,6 +82,7 @@ function createContentModal({
if (existingModal) { if (existingModal) {
existingModal.hidden = false; existingModal.hidden = false;
reopenWorkloadActions();
existingModal.querySelector("select").focus(); existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock"); document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler(); ensureEscapeKeyHandler();
@@ -85,6 +96,7 @@ function createContentModal({
authSessionState.tokenRelayRequest = null; authSessionState.tokenRelayRequest = null;
authSessionState.consumerTokenServiceRequest = null; authSessionState.consumerTokenServiceRequest = null;
authSessionState.accessToken = ""; authSessionState.accessToken = "";
authSessionState.principal = "";
opportunitiesQueryState.periodRequestVersion = 0; opportunitiesQueryState.periodRequestVersion = 0;
authSessionState.authStatus = AUTH_STATUS.idle; authSessionState.authStatus = AUTH_STATUS.idle;
resetOpportunitiesTable(); resetOpportunitiesTable();
@@ -422,6 +434,24 @@ function createContentModal({
? status ? status
: AUTH_STATUS.pending; : AUTH_STATUS.pending;
if (authCompletion && (authCompletion.badge !== badge || badgeStatus !== AUTH_STATUS.authenticated)) resetAuthCompletion();
badge.hidden = Boolean(authCompletion?.hidden);
if (badgeStatus === AUTH_STATUS.authenticated && !authCompletion) {
const current = { badge, hidden: false, waitTimer: null, hideTimer: null };
authCompletion = current;
badge.setAttribute("data-fading", "false");
current.waitTimer = setTimeout(() => {
if (authCompletion !== current) return;
badge.setAttribute("data-fading", "true");
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
current.hideTimer = setTimeout(() => {
if (authCompletion !== current) return;
current.hidden = true;
badge.hidden = true;
}, reducedMotion ? 0 : 300);
}, 5000);
}
badge.setAttribute("data-auth-status", badgeStatus); badge.setAttribute("data-auth-status", badgeStatus);
badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`; badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`;
badge.textContent = badgeStatus === AUTH_STATUS.authenticated badge.textContent = badgeStatus === AUTH_STATUS.authenticated
@@ -432,6 +462,8 @@ function createContentModal({
} }
function closeOpportunitiesModal() { function closeOpportunitiesModal() {
resetAuthCompletion();
resetWorkloadActions(false);
resetCrmServiceRequests(); resetCrmServiceRequests();
const modal = document.getElementById(MODAL_ID); const modal = document.getElementById(MODAL_ID);

View File

@@ -13,7 +13,7 @@ function createDebugContentView({
["all", "All"], ["authentication", "Authentication"], ["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["products", "Products"], ["opportunities", "Opportunities"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"], ["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
["serviceRequests", "Service Requests"], ["other", "Other"] ["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
]; ];
let selectedCategory = "all"; let selectedCategory = "all";
const expandedIds = new Set(); const expandedIds = new Set();
@@ -23,6 +23,7 @@ function createDebugContentView({
if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
if (entry.name === "requestOpportunities") return "opportunities"; if (entry.name === "requestOpportunities") return "opportunities";
if (entry.name === "requestOpptyProducts") return "products"; if (entry.name === "requestOpptyProducts") return "products";
if (entry.name === "requestWlActions") return "actions";
if (entry.name === "requestGenerateAiSummary") return "ai"; if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase"; if (entry.name === "requestInstallBase") return "installBase";
if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests"; if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";

View File

@@ -15,6 +15,7 @@ function createOpportunitiesController({
requestOpportunitiesPage, requestOpportunitiesPage,
loadCrmServiceRequests, loadCrmServiceRequests,
resetCrmServiceRequests, resetCrmServiceRequests,
resetWorkloadActions,
requestOpptyProductsInBackground, requestOpptyProductsInBackground,
requestTokenRelayOnce, requestTokenRelayOnce,
setOpportunitiesTableState setOpportunitiesTableState
@@ -106,6 +107,7 @@ function createOpportunitiesController({
} }
function resetOpportunitiesTable() { function resetOpportunitiesTable() {
resetWorkloadActions();
resetCrmServiceRequests(); resetCrmServiceRequests();
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS); opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS); opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);

View File

@@ -1,9 +1,9 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, environment }) { function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, environment }) {
let status = "idle"; let status = "idle";
let byOpportunity = new Map(); let byOpportunity = new Map();
let pending = null; let pending = null;
let generation = 0; let generation = 0;
function reset() { generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); } function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
function load(token, version) { function load(token, version) {
if (!isCurrentPeriodRequest(version)) return; if (!isCurrentPeriodRequest(version)) return;
if (!pending) pending = run(token, version, generation); if (!pending) pending = run(token, version, generation);
@@ -32,22 +32,25 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
function createBadge(optyNumber) { function createBadge(optyNumber) {
if (!optyNumber || status === "idle" || status === "loading") return null; if (!optyNumber || status === "idle" || status === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || []; const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement("span"); const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
badge.className = "opportunities-extension-crm-sr-badge"; badge.className = "opportunities-extension-crm-sr-badge";
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing"); badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
badge.textContent = status === "error" ? "SR ?" : items.length ? "\u2705 SR" : "\u26d4 SR"; badge.textContent = status === "error" ? "SR ?" : items.length ? "\u2705 SR" : "\u26d4 SR";
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value); const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
badge.title = status === "error" ? "Unable to check CRM service requests. See Debug Requests." const descriptions = items.map(item => [
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team." `Title: ${display(item.Title)}`,
: items.map(item => [
`SR Number: ${display(item.SrNumber)}`, `Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`, `Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`, `Reported Hours: ${display(item.ReportedHours_c)}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`, `Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`, `Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}` `Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
].join("\n")).join("\n\n"); ].join("\n"));
badge.setAttribute("aria-label", badge.title); const description = status === "error" ? "Unable to check CRM service requests. See Debug Requests."
: !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);
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
else badge.title = description;
badge.tabIndex = 0; badge.tabIndex = 0;
return badge; return badge;
} }

View File

@@ -0,0 +1,128 @@
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, environment }) {
const { document, window, setTimeout, clearTimeout } = environment;
let active = null;
let sequence = 0;
function close(restoreFocus = false) {
if (!active) return;
const entry = active;
active = null;
clearTimeout(entry.timer);
entry.panel.remove();
entry.button.setAttribute("aria-expanded", "false");
document.removeEventListener("pointerdown", entry.outside, true);
document.removeEventListener("keydown", entry.keydown, true);
window.removeEventListener("resize", entry.position);
document.removeEventListener("scroll", entry.scroll, true);
if (restoreFocus && entry.button.isConnected) {
entry.suppressFocus = true;
entry.button.focus();
entry.suppressFocus = false;
}
}
function bind(button, items, descriptions) {
button.type = "button";
button.setAttribute("aria-haspopup", "dialog");
button.setAttribute("aria-expanded", "false");
const id = `opportunities-extension-sr-details-${++sequence}`;
button.setAttribute("aria-controls", id);
const entry = { button, panel: null, timer: null, pinned: false, suppressFocus: false };
const contains = target => target && (button.contains(target) || entry.panel?.contains(target));
entry.outside = event => { if (!contains(event.target)) close(); };
entry.scroll = event => { if (!entry.panel.contains(event.target)) close(); };
entry.keydown = event => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close(true);
}
};
entry.position = () => {
const rect = button.getBoundingClientRect();
const width = Math.min(480, window.innerWidth - 24);
entry.panel.style.width = `${width}px`;
entry.panel.style.maxHeight = `${Math.min(480, window.innerHeight - 24)}px`;
const height = entry.panel.getBoundingClientRect().height;
entry.panel.style.left = `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`;
entry.panel.style.top = `${Math.max(12, Math.min(rect.bottom + 6, window.innerHeight - height - 12))}px`;
};
function leave() {
clearTimeout(entry.timer);
if (!entry.pinned && !contains(document.activeElement)) {
entry.timer = setTimeout(() => { if (active === entry) close(); }, 200);
}
}
function focusOut(event) { if (!contains(event.relatedTarget) && active === entry) close(); }
function open() {
if (entry.suppressFocus) return;
clearTimeout(entry.timer);
if (active === entry) return;
close();
const host = document.getElementById(MODAL_ID);
if (!host) return;
const panel = document.createElement("div");
entry.panel = panel;
entry.pinned = false;
panel.id = id;
panel.className = "opportunities-extension-sr-popover";
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-label", "CRM service requests");
const dismiss = document.createElement("button");
dismiss.type = "button";
dismiss.className = "opportunities-extension-sr-dismiss";
dismiss.title = "Close service request details";
dismiss.setAttribute("aria-label", dismiss.title);
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close(true));
panel.append(dismiss);
items.forEach((item, index) => {
const section = document.createElement("section");
const heading = document.createElement("div");
heading.className = "opportunities-extension-sr-number";
const number = item.SrNumber;
const link = document.createElement(number ? "a" : "span");
link.textContent = number ? String(number) : "N/D";
if (number) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
link.target = "_blank";
link.rel = "noopener noreferrer";
}
const label = document.createElement("span");
label.textContent = "SR Number:";
heading.append(label, link);
if (number) heading.append(createCopyButton(String(number), "SR number", "SR Number"));
const details = document.createElement("div");
details.className = "opportunities-extension-sr-details";
details.textContent = descriptions[index];
section.append(heading, details);
panel.append(section);
});
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
panel.addEventListener("pointerleave", leave);
panel.addEventListener("focusout", focusOut);
host.append(panel);
active = entry;
button.setAttribute("aria-expanded", "true");
entry.position();
document.addEventListener("pointerdown", entry.outside, true);
document.addEventListener("keydown", entry.keydown, true);
document.addEventListener("scroll", entry.scroll, true);
window.addEventListener("resize", entry.position);
}
button.addEventListener("pointerenter", open);
button.addEventListener("pointerleave", leave);
button.addEventListener("focus", open);
button.addEventListener("focusout", focusOut);
button.addEventListener("click", event => {
event.stopPropagation();
if (active === entry && entry.pinned) close();
else {
open();
if (active === entry) {
entry.pinned = true;
entry.panel.querySelector("a, button").focus();
}
}
});
}
return { bind, close };
}

View File

@@ -24,19 +24,19 @@ function createOpportunitiesLinks({
return link; return link;
} }
function createCopyOpportunityButton(optyNumber) { function createCopyOpportunityButton(optyNumber, label = "opportunity number", confirmationLabel = "Opty Number") {
const button = document.createElement("button"); const button = document.createElement("button");
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
button.type = "button"; button.type = "button";
button.className = "opportunities-extension-copy-button"; button.className = "opportunities-extension-copy-button";
button.setAttribute("aria-label", `Copy opportunity number ${optyNumber}`); button.setAttribute("aria-label", `Copy ${label} ${optyNumber}`);
button.title = "Copy opportunity number"; button.title = `Copy ${label}`;
button.addEventListener("click", (event) => { button.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
copyOpportunityNumber(String(optyNumber)); copyOpportunityNumber(String(optyNumber), label, confirmationLabel);
}); });
icon.setAttribute("viewBox", "0 0 24 24"); icon.setAttribute("viewBox", "0 0 24 24");
@@ -47,7 +47,7 @@ function createOpportunitiesLinks({
return button; return button;
} }
async function copyOpportunityNumber(optyNumber) { async function copyOpportunityNumber(optyNumber, label = "opportunity number", confirmationLabel = "Opty Number") {
try { try {
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(optyNumber); await navigator.clipboard.writeText(optyNumber);
@@ -63,9 +63,9 @@ function createOpportunitiesLinks({
input.remove(); input.remove();
} }
showCopyConfirmation(`Opty Number ${optyNumber} copied.`); showCopyConfirmation(`${confirmationLabel} ${optyNumber} copied.`);
} catch (error) { } catch (error) {
showCopyConfirmation("Unable to copy opportunity number.", true); showCopyConfirmation(`Unable to copy ${label}.`, true);
} }
} }

View File

@@ -1,4 +1,13 @@
/* Compact filters use available CSS width, including Windows display scaling. */ /* Compact filters use available CSS width, including Windows display scaling. */
.opportunities-extension-products-progress,
.opportunities-extension-auth-badge { opacity: 1; transition: opacity 300ms ease; }
.opportunities-extension-products-progress[data-fading="true"],
.opportunities-extension-auth-badge[data-fading="true"] { opacity: 0; }
.opportunities-extension-auth-badge[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-products-progress,
.opportunities-extension-auth-badge { transition: none; }
}
.opportunities-extension-crm-sr-badge { .opportunities-extension-crm-sr-badge {
flex: 0 0 auto; flex: 0 0 auto;
padding: 0 6px; padding: 0 6px;
@@ -13,10 +22,12 @@
} }
.opportunities-extension-crm-sr-badge[data-state="missing"] { color: #9f201b; background: #fde8e7; border-color: #c74640; } .opportunities-extension-crm-sr-badge[data-state="missing"] { color: #9f201b; background: #fde8e7; border-color: #c74640; }
.opportunities-extension-crm-sr-badge[data-state="error"] { color: #785000; background: #fff0cd; border-color: #987021; } .opportunities-extension-crm-sr-badge[data-state="error"] { color: #785000; background: #fff0cd; border-color: #987021; }
.opportunities-extension-wl-actions-badge[data-state="loading"] { color: #5f5a55; background: #eceae8; border-color: #8f8a85; }
.opportunities-extension-crm-sr-badge:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } .opportunities-extension-crm-sr-badge:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge { color: #b8e4c6; background: #214d34; border-color: #6eac82; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge { color: #b8e4c6; background: #214d34; border-color: #6eac82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="missing"] { color: #ffd9d5; background: #542824; border-color: #ef938b; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="missing"] { color: #ffd9d5; background: #542824; border-color: #ef938b; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="error"] { color: #ffe0a3; background: #584219; border-color: #c99b4b; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="error"] { color: #ffe0a3; background: #584219; border-color: #c99b4b; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-wl-actions-badge[data-state="loading"] { color: #dedbd7; background: #484541; border-color: #8f8a85; }
.opportunities-extension-form { .opportunities-extension-form {
display: grid; display: grid;
@@ -279,3 +290,24 @@
position: static; position: static;
justify-self: end; justify-self: end;
} }
.opportunities-extension-crm-sr-badge[type="button"] { cursor: pointer; font-family: inherit; }
.opportunities-extension-sr-popover {
position: fixed; z-index: 8; box-sizing: border-box; overflow: auto;
padding: 12px; border: 1px solid #b9b5b0; border-radius: 6px;
background: #fff; color: #302e2b; box-shadow: 0 6px 24px #0003;
font: 12px/1.5 Arial, sans-serif; text-align: left; overflow-wrap: anywhere;
}
.opportunities-extension-sr-popover section { padding: 8px 0; }
.opportunities-extension-sr-popover section + section { border-top: 1px solid #dedbd7; }
.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-dismiss {
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
display: grid; place-items: center; padding: 4px; border: 0;
background: transparent; color: inherit; cursor: pointer;
}
.opportunities-extension-sr-dismiss svg { width: 16px; height: 16px; }
.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; }

View File

@@ -11,6 +11,8 @@ function createOpportunitiesTable({
createProductsCountButton, createProductsCountButton,
createProductsRampBadge, createProductsRampBadge,
createCrmServiceRequestBadge, createCrmServiceRequestBadge,
closeCrmSrPopover,
createWorkloadActionsBadge,
createProductsDetailRow, createProductsDetailRow,
createStageBadge, createStageBadge,
createStatusBadge, createStatusBadge,
@@ -51,6 +53,7 @@ function createOpportunitiesTable({
return; return;
} }
closeCrmSrPopover();
table.textContent = ""; table.textContent = "";
table.setAttribute("data-opportunities-layout", "inline-actions"); table.setAttribute("data-opportunities-layout", "inline-actions");
table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false"); table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false");
@@ -137,6 +140,8 @@ function createOpportunitiesTable({
} }
const srBadge = createCrmServiceRequestBadge(item.OptyNumber); const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
if (srBadge) productActions.append(srBadge); if (srBadge) productActions.append(srBadge);
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
if (actionsBadge) productActions.append(actionsBadge);
nameCellContent.append(opportunityLink, productActions); nameCellContent.append(opportunityLink, productActions);
cell.append(nameCellContent); cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) { } else if (column.key === "optyNumber" && item.OptyNumber) {

View File

@@ -0,0 +1,87 @@
function createOpportunityWorkloadActions({ authSessionState, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
let session = newSession();
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; }
function reset(enabled = true) {
session.enabled = false;
session.queue.length = 0;
session = newSession();
session.enabled = enabled;
}
function idsFor(products) {
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
}
function loadProducts(products) {
if (!session.enabled) return;
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 });
}
pump(session);
}
function reopen() {
reset();
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
queueProductsTableRender();
}
function pump(current) {
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
const job = current.queue.shift();
current.running += 1;
run(current, job).finally(() => { current.running -= 1; pump(current); });
}
}
async function run(current, { revnId, record }) {
try {
const authorizations = await requestConsumerTokenServiceOnce();
if (!current.enabled || current !== session) return;
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
if (!current.enabled || current !== session) return;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
record.actions = result.actions;
record.hasNext = result.hasNext;
record.status = "ready";
} 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();
}
}
function createBadge(optyNumber) {
if (!session.enabled || !optyNumber) return null;
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
if (!products) return null;
const ids = idsFor(products);
const records = ids.map(id => session.records.get(id));
const actions = records.flatMap(record => record?.actions || []);
const pending = records.some(record => !record || record.status === "loading");
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 state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
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 ?";
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"}`,
`Description: ${action.workloadDescription || "N/D"}`, `Start: ${action.startDate || "N/D"}`, `End: ${action.endDate || "N/D"}`
].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products."
: state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests.";
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled.";
if (!principal) badge.title += "\nTokenrelay principal is unavailable; ownership cannot be verified.";
else if (actions.length && state === "missing") badge.title += "\nNone of the returned actions belong to the signed-in principal.";
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
badge.tabIndex = 0;
badge.setAttribute("aria-label", badge.title);
return badge;
}
return { reset, reopen, loadProducts, createBadge };
}

View File

@@ -7,10 +7,43 @@ function createProductsContentJobs({
preferencesState, preferencesState,
productsContentState, productsContentState,
renderOpportunitiesTable, renderOpportunitiesTable,
loadWorkloadActions,
sendRuntimeMessage, sendRuntimeMessage,
environment environment
}) { }) {
const { browser, chrome, document, window } = environment; const { browser, chrome, document, window, setTimeout, clearTimeout, matchMedia } = environment;
let completion = null;
function updateCompletionVisibility(badge) {
const requestId = productsContentState.currentProductsRequestId;
const complete = productsContentState.productsProgress.state === "complete";
if (completion && (!complete || completion.requestId !== requestId || completion.badge !== badge)) {
clearTimeout(completion.waitTimer);
clearTimeout(completion.hideTimer);
completion.badge.setAttribute("data-fading", "false");
completion = null;
}
if (!complete) {
badge.setAttribute("data-fading", "false");
return;
}
if (completion) {
badge.hidden = completion.hidden;
return;
}
const current = { requestId, badge, hidden: false, waitTimer: null, hideTimer: null };
completion = current;
badge.setAttribute("data-fading", "false");
current.waitTimer = setTimeout(() => {
if (completion !== current) return;
badge.setAttribute("data-fading", "true");
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
current.hideTimer = setTimeout(() => {
if (completion !== current) return;
current.hidden = true;
badge.hidden = true;
}, reducedMotion ? 0 : 300);
}, 5000);
}
function requestOpptyProductsInBackground(opportunities, token) { function requestOpptyProductsInBackground(opportunities, token) {
const optyNumbers = Array.from(new Set(opportunities const optyNumbers = Array.from(new Set(opportunities
.map((item) => item && item.OptyNumber) .map((item) => item && item.OptyNumber)
@@ -86,6 +119,7 @@ function createProductsContentJobs({
cachedAt: Number(message.cachedAt) || 0, cachedAt: Number(message.cachedAt) || 0,
error: message.error || "" error: message.error || ""
}); });
loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber)));
} }
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) { if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
@@ -130,6 +164,7 @@ function createProductsContentJobs({
} else { } else {
badge.textContent = "Products ready 0"; badge.textContent = "Products ready 0";
} }
updateCompletionVisibility(badge);
const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID); const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID);

View File

@@ -0,0 +1,29 @@
function createSpaWorkloadActions({ fetchJson }) {
async function requestWlActions(message) {
const debugRequests = [];
try {
const revnId = String(message.revnId ?? "").trim();
const token = message.authorization?.value;
if (!/^\d+$/.test(revnId)) throw new Error("A numeric RevnId is required.");
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const url = `https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions?parentType=WORKLOAD&parentId=${encodeURIComponent(revnId)}`;
const result = await fetchJson(url, {
method: "GET", credentials: "include",
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
}, "requestWlActions", debugRequests);
if (!result || !Array.isArray(result.actions) || typeof result.hasNext !== "boolean"
|| result.actions.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
throw new Error("requestWlActions returned an invalid collection.");
}
return { ok: true, actions: result.actions, hasNext: result.hasNext, 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 { requestWlActions };
}

View File

@@ -10,6 +10,7 @@ function createMessageTypes() {
requestAccountPlans: "opportunitiesExtension.requestAccountPlans", requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase", requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail", requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan" requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"

View File

@@ -3,7 +3,7 @@ const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs"); const { loadApplication } = require("./support/application.cjs");
const response = data => new Response(JSON.stringify(data), { status: 200 }); const response = data => new Response(JSON.stringify(data), { status: 200 });
function setup(fetch) { function setup(fetch) {
const document = { createElement: () => ({ attributes: {}, setAttribute(key, value) { this.attributes[key] = value; } }) }; const document = { createElement: () => ({ attributes: {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
const app = loadApplication("content", { fetch, document, Intl, Date }); const app = loadApplication("content", { fetch, document, Intl, Date });
app.debugContentView.renderDebugPanel = () => {}; app.debugContentView.renderDebugPanel = () => {};
app.opportunitiesQueryState.periodRequestVersion = 1; app.opportunitiesQueryState.periodRequestVersion = 1;
@@ -81,7 +81,7 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
assert.equal(found.textContent, "\u2705 SR"); assert.equal(found.textContent, "\u2705 SR");
assert.equal(found.attributes["data-state"], "matched"); assert.equal(found.attributes["data-state"], "matched");
for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"]) for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"])
assert.ok(found.title.includes(detail)); assert.ok(found.attributes["aria-label"].includes(detail));
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR"); assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null); assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
app.opportunityCrmServiceRequests.reset(); app.opportunityCrmServiceRequests.reset();

105
tests/crmSrPopover.test.cjs Normal file
View File

@@ -0,0 +1,105 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
function setup() {
const document = { activeElement: null, listeners: new Map() };
const window = { innerWidth: 400, innerHeight: 600, listeners: new Map(), setTimeout, clearTimeout };
for (const target of [document, window]) {
target.addEventListener = (key, fn) => target.listeners.set(key, fn);
target.removeEventListener = key => target.listeners.delete(key);
}
const walk = node => [node, ...node.children.flatMap(walk)];
function element(tag) {
return {
tag, children: [], attributes: {}, listeners: {}, style: {}, textContent: "",
append(...nodes) { nodes.forEach(node => { node.parent = this; this.children.push(node); }); },
remove() { this.parent.children = this.parent.children.filter(node => node !== this); this.parent = null; },
setAttribute(key, value) { this.attributes[key] = value; },
addEventListener(key, fn) { this.listeners[key] = fn; },
contains(target) { return walk(this).includes(target); },
get isConnected() { return this === modal || !!this.parent?.isConnected; },
getBoundingClientRect() { return { left: 350, bottom: 550, height: 250 }; },
querySelector() { return walk(this).find(node => node.tag === "a" || node.tag === "button"); },
focus() { document.activeElement = this; this.listeners.focus?.(); }
};
}
const modal = element("main");
Object.assign(document, {
createElement: element, createElementNS: (_, tag) => element(tag),
getElementById: id => id === "opportunities-extension-modal" ? modal : null
});
const copied = [];
const app = loadApplication("content", { document, window, setTimeout, clearTimeout,
navigator: { clipboard: { writeText: async value => copied.push(value) } } });
// Keep the copy test independent of the toast's own DOM/timer implementation.
modal.querySelector = () => ({ classList: { toggle() {} } });
window.setTimeout = () => 0;
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
{ SrId: "NOT-THE-URL-ID", SrNumber: "SR /?&1", Title: "<script>synthetic</script>",
ReportedHours_c: 0, OpportunityName_Id_c: { OptyNumber: "OP1" } },
{ SrNumber: "SR2", OpportunityName_Id_c: { OptyNumber: "OP1" } }
];
app.opportunitiesQueryState.periodRequestVersion = 1;
const panel = () => modal.children.find(node => node.className === "opportunities-extension-sr-popover");
return { app, document, window, modal, walk, copied, panel };
}
test("SR popover uses encoded SrNumber, safe details and existing copy control for every record", async () => {
const { app, modal, walk, copied, panel } = setup();
await app.opportunityCrmServiceRequests.load("synthetic", 1);
const badge = app.opportunityCrmServiceRequests.createBadge("OP1");
modal.append(badge);
badge.listeners.pointerenter();
const nodes = walk(panel());
const links = nodes.filter(node => node.tag === "a");
assert.equal(links.length, 2);
for (const link of links) {
assert.equal(link.parent.children[0].textContent, "SR Number:");
assert.equal(link.parent.children[2].className, "opportunities-extension-copy-button");
}
const details = nodes.filter(node => node.className === "opportunities-extension-sr-details");
assert.equal(details.length, 2);
details.forEach(node => assert.doesNotMatch(node.textContent, /SR Number:|SR \/\?&1|SR2/));
assert.equal(links[0].href, "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR%20%2F%3F%261");
assert.equal(links[0].rel, "noopener noreferrer");
assert.equal(links[0].target, "_blank");
assert.ok(nodes.some(node => node.textContent.includes("<script>synthetic</script>")));
assert.equal(nodes.some(node => node.tag === "script"), false);
const copies = nodes.filter(node => node.className === "opportunities-extension-copy-button");
assert.equal(copies.length, 2);
assert.equal(copies[0].title, "Copy SR number");
copies[0].listeners.click({ preventDefault() {}, stopPropagation() {} });
await Promise.resolve();
assert.deepEqual(copied, ["SR /?&1"]);
assert.equal(panel().style.width, "376px");
app.opportunityCrmServiceRequests.reset();
assert.equal(panel(), undefined);
});
test("SR popover pins on click, closes on Escape/outside/scroll/reset and removes global listeners", async () => {
const { app, modal, document, window, panel } = setup();
await app.opportunityCrmServiceRequests.load("synthetic", 1);
const badge = app.opportunityCrmServiceRequests.createBadge("OP1");
modal.append(badge);
badge.listeners.click({ stopPropagation() {} });
assert.equal(badge.attributes["aria-expanded"], "true");
assert.ok(panel().contains(document.activeElement));
document.listeners.get("keydown")({ key: "Escape", preventDefault() {}, stopPropagation() {} });
assert.equal(panel(), undefined);
assert.equal(document.activeElement, badge);
assert.equal(document.listeners.size, 0);
assert.equal(window.listeners.size, 0);
badge.listeners.pointerenter();
document.listeners.get("pointerdown")({ target: modal });
assert.equal(panel(), undefined);
badge.listeners.pointerenter();
document.listeners.get("scroll")({ target: panel() });
assert.ok(panel(), "scroll inside the tooltip keeps it open");
document.listeners.get("scroll")({ target: modal });
assert.equal(panel(), undefined);
badge.listeners.pointerenter();
app.opportunityCrmServiceRequests.reset();
assert.equal(panel(), undefined);
assert.equal(document.listeners.size, 0);
});

View File

@@ -127,7 +127,7 @@ test("CRM SR badge follows Ramp Months and remains visible while product data is
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] }); h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
h.app.opportunitiesTable.renderOpportunitiesTable(); h.app.opportunitiesTable.renderOpportunitiesTable();
const actions = h.cell("name").children[0].children[1]; const actions = h.cell("name").children[0].children[1];
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "RM 3", "\u2705 SR"]); assert.deepEqual(actions.children.map(child => child.textContent), ["1", "RM 3", "\u2705 SR", "Act ?"]);
findClass(h.cell("name"), "opportunities-extension-products-count").click(); findClass(h.cell("name"), "opportunities-extension-products-count").click();
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table")); assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
}); });

View File

@@ -0,0 +1,109 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
function fixture(reduced = false) {
let now = 0, nextId = 0;
const timers = new Map();
const badge = { hidden: false, attributes: {}, setAttribute(key, value) { this.attributes[key] = value; } };
const timestamp = {};
const app = loadApplication("content", {
document: { getElementById: id => id === app.contracts.PRODUCTS_PROGRESS_ID ? badge : timestamp, querySelector: () => badge },
setTimeout: (fn, delay) => { const id = ++nextId; timers.set(id, { fn, at: now + delay }); return id; },
clearTimeout: id => timers.delete(id), matchMedia: () => ({ matches: reduced })
});
const render = state => {
app.productsContentState.productsProgress = { state, completed: 2, total: 2, failed: 0, cacheHits: 0 };
app.productsContentJobs.renderProductsProgress();
};
function advance(ms) {
const end = now + ms;
while (true) {
const entry = [...timers].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
if (!entry) break;
now = entry[1].at;
timers.delete(entry[0]);
entry[1].fn();
}
now = end;
}
app.productsContentState.currentProductsRequestId = "first";
app.productsContentState.productsLastCachedAt = 1;
return { app, badge, timestamp, render, advance, timers };
}
test("Products ready stays for five seconds then fades for 300ms before hidden", () => {
const h = fixture();
h.render("complete");
h.advance(4999);
assert.equal(h.badge.hidden, false);
assert.equal(h.badge.attributes["data-fading"], "false");
h.render("complete");
h.advance(1);
assert.equal(h.badge.attributes["data-fading"], "true");
assert.equal(h.badge.hidden, false);
h.advance(300);
assert.equal(h.badge.hidden, true);
h.render("complete");
assert.equal(h.badge.hidden, true, "rerenders do not restart the completion timer");
assert.equal(h.timestamp.hidden, false, "Last update remains visible");
});
test("new product jobs cancel a pending fade and errors remain visible", () => {
const h = fixture();
h.render("complete");
h.advance(5000);
h.app.productsContentState.currentProductsRequestId = "second";
h.render("running");
h.advance(10000);
assert.equal(h.badge.hidden, false);
assert.equal(h.badge.attributes["data-fading"], "false");
assert.equal(h.timers.size, 0);
for (const state of ["complete-with-errors", "error"]) {
h.render(state);
h.advance(10000);
assert.equal(h.badge.hidden, false);
}
h.render("complete");
h.advance(5300);
assert.equal(h.badge.hidden, true);
});
test("reduced motion hides at five seconds without an animated delay", () => {
const h = fixture(true);
h.render("complete");
h.advance(5000);
assert.equal(h.badge.hidden, true);
});
test("Authenticated fades after five seconds without restarting on repeated status updates", () => {
const h = fixture();
const update = () => h.app.contentModal.updateAuthBadge(h.app.contracts.AUTH_STATUS.authenticated);
update();
h.advance(4999);
assert.equal(h.badge.hidden, false);
update();
h.advance(1);
assert.equal(h.badge.attributes["data-fading"], "true");
h.advance(300);
assert.equal(h.badge.hidden, true);
update();
assert.equal(h.badge.hidden, true);
h.app.contentModal.updateAuthBadge(h.app.contracts.AUTH_STATUS.unauthenticated);
h.advance(10000);
assert.equal(h.badge.hidden, false);
assert.equal(h.badge.attributes["data-fading"], "false");
update();
h.advance(5000);
h.app.contentModal.updateAuthBadge(h.app.contracts.AUTH_STATUS.pending);
h.advance(300);
assert.equal(h.badge.hidden, false);
assert.equal(h.badge.textContent, "Authenticating");
});
test("Authenticated respects reduced motion", () => {
const h = fixture(true);
h.app.contentModal.updateAuthBadge(h.app.contracts.AUTH_STATUS.authenticated);
h.advance(5000);
assert.equal(h.badge.hidden, true);
});

View File

@@ -0,0 +1,185 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
for (const family of ["chrome", "firefox"]) {
test(`${family}: WWB-TS selection, fixed workload header, first-page GET and masked debug`, 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" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "consumption" } },
{ tokenService: "WWB-TS", uiaasHeader: { label: "ignored-label", value: "synthetic-wwb" } }
]);
return response({ actions: [{ id: 1, name: "TEST" }], totalCount: 20, hasNext: true });
} });
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
assert.equal(auth.authorizations.workloadActions.value, "synthetic-wwb");
const result = await h.dispatch({ type: "opportunitiesExtension.requestWlActions", revnId: "12345", authorization: auth.authorizations.workloadActions });
assert.equal(result.ok, true);
assert.equal(result.hasNext, true);
assert.equal(result.actions.length, 1);
assert.equal(calls.length, 2, "no pagination");
assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions?parentType=WORKLOAD&parentId=12345");
assert.equal(calls[1].options.method, "GET");
assert.equal(calls[1].options.headers["Wwb-Provider-Authorization"], "synthetic-wwb");
assert.equal(calls[1].options.headers.Authorization, undefined);
assert.equal(result.debugRequests[0].name, "requestWlActions");
assert.equal(result.debugRequests[0].requestHeaders["Wwb-Provider-Authorization"], "(present)");
});
}
test("workload actions reject missing credentials, invalid ids, HTTP/network/JSON failures", async () => {
for (const fetch of [
async () => { throw new Error("Synthetic network failure"); },
async () => new Response("Denied", { status: 401 }),
async () => new Response("Denied", { status: 403 }),
async () => new Response("<html>Login</html>"),
async () => response({ actions: null, hasNext: false })
]) {
const h = loadBackground({ fetch });
const result = await h.dispatch({ type: "opportunitiesExtension.requestWlActions", revnId: "123", authorization: { value: "synthetic" } });
assert.equal(result.ok, false);
assert.equal(result.debugRequests.length, 1);
}
const h = loadBackground({ fetch: () => assert.fail("Unexpected network") });
for (const fields of [{ revnId: "123" }, { revnId: "bad&id=2", authorization: { value: "synthetic" } }]) {
assert.equal((await h.dispatch({ type: "opportunitiesExtension.requestWlActions", ...fields })).ok, false);
}
});
function contentFixture(send) {
const app = loadApplication("content", { document: { createElement: () => ({ attributes: {}, setAttribute(k, v) { this.attributes[k] = v; } }) } });
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
app.authSessionState.principal = "test@example.invalid";
app.platformContentMessages.sendRuntimeMessage = send;
app.debugContentView.renderDebugPanel = () => {};
app.productsContentJobs.queueProductsTableRender = () => {};
return app;
}
test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
const calls = [];
const app = contentFixture(async message => {
calls.push(message);
return { ok: true, hasNext: false, actions: message.revnId === "1" ? [{ id: 1, owner: "test@example.invalid", name: "<script>safe</script>" }] : [] };
});
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 ...");
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.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");
app.opportunityWorkloadActions.reset(false);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(calls.length, 2);
app.opportunityWorkloadActions.reopen();
await flush();
assert.equal(calls.length, 4);
assert.equal(app.debugContentView.getRequestCategory({ name: "requestWlActions" }), "actions");
});
test("errors, missing ids and partial empty pages never appear as confirmed absence", async () => {
for (const result of [{ ok: false, error: "Synthetic" }, { ok: true, actions: [], hasNext: true }]) {
const app = contentFixture(async () => result);
const products = { items: [{ RevnId: "1" }] };
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
app.productsContentState.opptyProductsByNumber.set("A", { items: [{}] });
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
}
});
test("Act requires an owner matching tokenrelay principal while tooltip retains every action", async () => {
const actions = [
{ id: 1, name: "Other action", owner: "other@example.invalid", ownerEmail: "test@example.invalid" },
{ id: 2, name: "My action", owner: "test@example.invalid", ownerEmail: "different@example.invalid" }
];
const app = contentFixture(async () => ({ ok: true, actions, hasNext: false }));
const products = { items: [{ RevnId: "1" }] };
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
const badge = () => app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge().textContent, "\u2705 Act");
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.match(badge().title, /Other action/);
assert.match(badge().title, /My action/);
app.authSessionState.principal = "";
assert.equal(badge().textContent, "Act ?");
});
test("general tokenrelay retains principal without changing access-token return or accepting sales-common identity", async () => {
let payload = { access_token: "synthetic-token", principal: "test@example.invalid" };
const app = loadApplication("content", { fetch: async () => response(payload) });
app.authXsrfContent.getXsrfToken = async () => ({ token: "synthetic-xsrf" });
app.debugContentView.renderDebugPanel = () => {};
assert.equal(await app.authTokenRelay.requestTokenRelay(), "synthetic-token");
assert.equal(app.authSessionState.principal, "test@example.invalid");
payload = { access_token: "scoped-token", principal: "other@example.invalid", expires_in: 3600 };
await app.authTokenRelay.requestTokenRelay({ url: `${app.contracts.TOKEN_RELAY_URL}?scopesfq=synthetic`, includeExpiry: true });
assert.equal(app.authSessionState.principal, "test@example.invalid");
payload = { access_token: "synthetic-token" };
await app.authTokenRelay.requestTokenRelay();
assert.equal(app.authSessionState.principal, "");
});
test("actions queue caps concurrency and discards results/queued jobs from closed modals", async () => {
const pending = [];
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
const products = { items: Array.from({ length: 8 }, (_, i) => ({ RevnId: String(i + 1) })) };
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(pending.length, 4);
app.opportunityWorkloadActions.reset(false);
pending.forEach(resolve => resolve({ ok: true, hasNext: false, actions: [{ id: 1 }] }));
await flush();
assert.equal(pending.length, 4);
assert.equal(app.opportunityWorkloadActions.createBadge("A"), null);
});
test("product progress triggers Actions for cached products and ignores stale product jobs", async () => {
let listener;
const calls = [];
const app = loadApplication("content", {
document: { getElementById: () => null },
window: { requestAnimationFrame() {} },
chrome: { runtime: { onMessage: { addListener(fn) { listener = fn; } } } }
});
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
app.platformContentMessages.sendRuntimeMessage = async message => {
calls.push(message);
return { ok: true, actions: [], hasNext: false };
};
app.productsContentState.currentProductsRequestId = "current";
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
const message = { type: app.messageTypes?.opptyProductsProgress || "opportunitiesExtension.opptyProductsProgress",
requestId: "current", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: true };
listener({ ...message, requestId: "old" });
await flush();
assert.equal(calls.length, 0);
listener(message);
await flush();
assert.equal(calls.length, 1);
assert.equal(calls[0].type, "opportunitiesExtension.requestWlActions");
assert.equal(calls[0].revnId, "123");
});