Badge indicator showing whether an opportunity has an associated Workload Workbench Action
This commit is contained in:
14
README.md
14
README.md
@@ -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
|
||||
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
|
||||
|
||||
Use Node.js:
|
||||
|
||||
48
dist/chromium/background.js
vendored
48
dist/chromium/background.js
vendored
@@ -13,6 +13,7 @@ function createMessageTypes() {
|
||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||
@@ -204,7 +205,8 @@ function createAuthConsumerSession({
|
||||
accountSummary: accountSummaryAuthorization,
|
||||
accountPlans: accountPlansAuthorization,
|
||||
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
|
||||
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "")
|
||||
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", ""),
|
||||
workloadActions: extractTokenServiceAuthorization(tokenResponse, "WWB-TS", "Wwb-Provider-Authorization")
|
||||
},
|
||||
debugRequests
|
||||
};
|
||||
@@ -610,6 +612,37 @@ function createSpaCustomerServiceRequests({ ACCOUNT_SUMMARY_URL, fetchJson, find
|
||||
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
|
||||
function createSpaConsumption({
|
||||
CONSUMPTION_TREND_REQUESTS,
|
||||
@@ -1662,6 +1695,7 @@ function registerBackgroundMessages({
|
||||
requestAccountPlans,
|
||||
requestInstallBase,
|
||||
requestCustomerServiceRequest,
|
||||
requestWlActions,
|
||||
requestConsumerTokenService,
|
||||
requestConsumptionTrend,
|
||||
requestConsumptionPlanDetail,
|
||||
@@ -1743,6 +1777,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
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) {
|
||||
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load service requests.",
|
||||
@@ -1921,6 +1963,8 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...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 requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||
@@ -1929,7 +1973,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
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 {
|
||||
start, contracts,
|
||||
|
||||
368
dist/chromium/content.js
vendored
368
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
48
dist/firefox/background.js
vendored
48
dist/firefox/background.js
vendored
@@ -13,6 +13,7 @@ function createMessageTypes() {
|
||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||
@@ -204,7 +205,8 @@ function createAuthConsumerSession({
|
||||
accountSummary: accountSummaryAuthorization,
|
||||
accountPlans: accountPlansAuthorization,
|
||||
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
|
||||
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "")
|
||||
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", ""),
|
||||
workloadActions: extractTokenServiceAuthorization(tokenResponse, "WWB-TS", "Wwb-Provider-Authorization")
|
||||
},
|
||||
debugRequests
|
||||
};
|
||||
@@ -610,6 +612,37 @@ function createSpaCustomerServiceRequests({ ACCOUNT_SUMMARY_URL, fetchJson, find
|
||||
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
|
||||
function createSpaConsumption({
|
||||
CONSUMPTION_TREND_REQUESTS,
|
||||
@@ -1662,6 +1695,7 @@ function registerBackgroundMessages({
|
||||
requestAccountPlans,
|
||||
requestInstallBase,
|
||||
requestCustomerServiceRequest,
|
||||
requestWlActions,
|
||||
requestConsumerTokenService,
|
||||
requestConsumptionTrend,
|
||||
requestConsumptionPlanDetail,
|
||||
@@ -1743,6 +1777,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
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) {
|
||||
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load service requests.",
|
||||
@@ -1921,6 +1963,8 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...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 requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||
@@ -1929,7 +1973,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
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 {
|
||||
start, contracts,
|
||||
|
||||
368
dist/firefox/content.js
vendored
368
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -31,6 +31,8 @@ module.exports = {
|
||||
"src/services/fusion/opportunities.js",
|
||||
"src/services/fusion/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/table.js",
|
||||
"src/features/opportunities/dashboard.js",
|
||||
@@ -73,6 +75,7 @@ module.exports = {
|
||||
"src/services/spa/accounts.js",
|
||||
"src/services/spa/install-base.js",
|
||||
"src/services/spa/customer-service-requests.js",
|
||||
"src/services/spa/workload-actions.js",
|
||||
"src/services/spa/consumption.js",
|
||||
"src/services/spa/response-shapes.js",
|
||||
"src/services/http/background-json.js",
|
||||
|
||||
@@ -60,7 +60,8 @@ function createAuthConsumerSession({
|
||||
accountSummary: accountSummaryAuthorization,
|
||||
accountPlans: accountPlansAuthorization,
|
||||
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
|
||||
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "")
|
||||
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", ""),
|
||||
workloadActions: extractTokenServiceAuthorization(tokenResponse, "WWB-TS", "Wwb-Provider-Authorization")
|
||||
},
|
||||
debugRequests
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ function createAuthContentSession({
|
||||
})
|
||||
.catch((error) => {
|
||||
authSessionState.accessToken = "";
|
||||
authSessionState.principal = "";
|
||||
authSessionState.authStatus = AUTH_STATUS.unauthenticated;
|
||||
throw error;
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ function createAuthSessionState({ AUTH_STATUS } = {}) {
|
||||
tokenRelayRequest: null,
|
||||
consumerTokenServiceRequest: null,
|
||||
accessToken: "",
|
||||
principal: "",
|
||||
authStatus: AUTH_STATUS.idle
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function createAuthTokenRelay({
|
||||
TOKEN_RELAY_URL,
|
||||
authSessionState,
|
||||
createDebugBody,
|
||||
getXsrfToken,
|
||||
logRequestFailure,
|
||||
@@ -13,6 +14,7 @@ function createAuthTokenRelay({
|
||||
}) {
|
||||
const { fetch } = environment;
|
||||
async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) {
|
||||
if (url === TOKEN_RELAY_URL) authSessionState.principal = "";
|
||||
let refreshResult = null;
|
||||
let refreshAttempted = false;
|
||||
|
||||
@@ -95,6 +97,9 @@ function createAuthTokenRelay({
|
||||
access_token: maskToken(responseData.access_token)
|
||||
}, null, 2)));
|
||||
|
||||
if (url === TOKEN_RELAY_URL) {
|
||||
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
|
||||
}
|
||||
if (includeExpiry) {
|
||||
const lifetime = Number(responseData.expires_in);
|
||||
return {
|
||||
|
||||
@@ -111,6 +111,8 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...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 requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||
@@ -119,7 +121,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
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 {
|
||||
start, contracts,
|
||||
|
||||
@@ -6,6 +6,7 @@ function registerBackgroundMessages({
|
||||
requestAccountPlans,
|
||||
requestInstallBase,
|
||||
requestCustomerServiceRequest,
|
||||
requestWlActions,
|
||||
requestConsumerTokenService,
|
||||
requestConsumptionTrend,
|
||||
requestConsumptionPlanDetail,
|
||||
@@ -87,6 +88,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
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) {
|
||||
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load service requests.",
|
||||
|
||||
@@ -104,6 +104,8 @@ 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),
|
||||
@@ -143,6 +145,7 @@ function createContentApplication(overrides = {}) {
|
||||
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args)
|
||||
});
|
||||
const authTokenRelay = createAuthTokenRelay({
|
||||
authSessionState,
|
||||
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
|
||||
createDebugBody: (...args) => debugContentLog.createDebugBody(...args),
|
||||
getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args),
|
||||
@@ -164,6 +167,7 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
const opportunitiesController = createOpportunitiesController({
|
||||
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
||||
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
||||
authSessionState,
|
||||
@@ -205,11 +209,20 @@ function createContentApplication(overrides = {}) {
|
||||
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||
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({
|
||||
bindPopover: (...args) => crmSrPopover.bind(...args),
|
||||
closePopover: (...args) => crmSrPopover.close(...args),
|
||||
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
||||
});
|
||||
const opportunitiesTable = createOpportunitiesTable({
|
||||
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
||||
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
||||
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
|
||||
OPPORTUNITIES_COLUMNS: columns,
|
||||
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
|
||||
@@ -309,6 +322,7 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
const productsContentJobs = createProductsContentJobs({
|
||||
loadWorkloadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
messageTypes,
|
||||
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||
@@ -320,6 +334,14 @@ function createContentApplication(overrides = {}) {
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
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({
|
||||
OPPORTUNITIES_COLUMNS: columns,
|
||||
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
||||
@@ -485,6 +507,7 @@ function createContentApplication(overrides = {}) {
|
||||
}
|
||||
return {
|
||||
start, contracts,
|
||||
opportunityWorkloadActions,
|
||||
fusionCrmServiceRequests, opportunityCrmServiceRequests,
|
||||
authSessionState,
|
||||
authSalesCommonState,
|
||||
|
||||
@@ -53,6 +53,8 @@ function createContentModal({
|
||||
requestOpportunitiesForPeriod,
|
||||
loadCrmServiceRequests,
|
||||
resetCrmServiceRequests,
|
||||
resetWorkloadActions,
|
||||
reopenWorkloadActions,
|
||||
requestTokenRelayOnce,
|
||||
resetCustomerAndOwnerFiltersForPeriod,
|
||||
resetOpportunitiesTable,
|
||||
@@ -63,7 +65,15 @@ function createContentModal({
|
||||
toggleOwnerFilterPanel,
|
||||
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() {
|
||||
ensureExtensionStyles();
|
||||
debugState.debugSecretsVisible = false;
|
||||
@@ -72,6 +82,7 @@ function createContentModal({
|
||||
|
||||
if (existingModal) {
|
||||
existingModal.hidden = false;
|
||||
reopenWorkloadActions();
|
||||
existingModal.querySelector("select").focus();
|
||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||
ensureEscapeKeyHandler();
|
||||
@@ -85,6 +96,7 @@ function createContentModal({
|
||||
authSessionState.tokenRelayRequest = null;
|
||||
authSessionState.consumerTokenServiceRequest = null;
|
||||
authSessionState.accessToken = "";
|
||||
authSessionState.principal = "";
|
||||
opportunitiesQueryState.periodRequestVersion = 0;
|
||||
authSessionState.authStatus = AUTH_STATUS.idle;
|
||||
resetOpportunitiesTable();
|
||||
@@ -422,6 +434,24 @@ function createContentModal({
|
||||
? status
|
||||
: 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.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`;
|
||||
badge.textContent = badgeStatus === AUTH_STATUS.authenticated
|
||||
@@ -432,6 +462,8 @@ function createContentModal({
|
||||
}
|
||||
|
||||
function closeOpportunitiesModal() {
|
||||
resetAuthCompletion();
|
||||
resetWorkloadActions(false);
|
||||
resetCrmServiceRequests();
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ function createDebugContentView({
|
||||
["all", "All"], ["authentication", "Authentication"],
|
||||
["opportunities", "Opportunities"], ["products", "Products"],
|
||||
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
|
||||
["serviceRequests", "Service Requests"], ["other", "Other"]
|
||||
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
|
||||
];
|
||||
let selectedCategory = "all";
|
||||
const expandedIds = new Set();
|
||||
@@ -23,6 +23,7 @@ function createDebugContentView({
|
||||
if (/^request(?: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 (entry.name === "requestGenerateAiSummary") return "ai";
|
||||
if (entry.name === "requestInstallBase") return "installBase";
|
||||
if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";
|
||||
|
||||
@@ -15,6 +15,7 @@ function createOpportunitiesController({
|
||||
requestOpportunitiesPage,
|
||||
loadCrmServiceRequests,
|
||||
resetCrmServiceRequests,
|
||||
resetWorkloadActions,
|
||||
requestOpptyProductsInBackground,
|
||||
requestTokenRelayOnce,
|
||||
setOpportunitiesTableState
|
||||
@@ -106,6 +107,7 @@ function createOpportunitiesController({
|
||||
}
|
||||
|
||||
function resetOpportunitiesTable() {
|
||||
resetWorkloadActions();
|
||||
resetCrmServiceRequests();
|
||||
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
|
||||
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, environment }) {
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, environment }) {
|
||||
let status = "idle";
|
||||
let byOpportunity = new Map();
|
||||
let pending = null;
|
||||
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) {
|
||||
if (!isCurrentPeriodRequest(version)) return;
|
||||
if (!pending) pending = run(token, version, generation);
|
||||
@@ -32,22 +32,25 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
function createBadge(optyNumber) {
|
||||
if (!optyNumber || status === "idle" || status === "loading") return null;
|
||||
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.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
|
||||
badge.textContent = status === "error" ? "SR ?" : items.length ? "\u2705 SR" : "\u26d4 SR";
|
||||
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."
|
||||
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
|
||||
: items.map(item => [
|
||||
`SR Number: ${display(item.SrNumber)}`, `Title: ${display(item.Title)}`,
|
||||
const descriptions = items.map(item => [
|
||||
`Title: ${display(item.Title)}`,
|
||||
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
|
||||
`Reported Hours: ${display(item.ReportedHours_c)}`,
|
||||
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
|
||||
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
|
||||
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
||||
].join("\n")).join("\n\n");
|
||||
badge.setAttribute("aria-label", badge.title);
|
||||
].join("\n"));
|
||||
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;
|
||||
return badge;
|
||||
}
|
||||
|
||||
128
src/features/opportunities/crm-sr-popover.js
Normal file
128
src/features/opportunities/crm-sr-popover.js
Normal 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 };
|
||||
}
|
||||
@@ -24,19 +24,19 @@ function createOpportunitiesLinks({
|
||||
return link;
|
||||
}
|
||||
|
||||
function createCopyOpportunityButton(optyNumber) {
|
||||
function createCopyOpportunityButton(optyNumber, label = "opportunity number", confirmationLabel = "Opty Number") {
|
||||
const button = document.createElement("button");
|
||||
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-copy-button";
|
||||
button.setAttribute("aria-label", `Copy opportunity number ${optyNumber}`);
|
||||
button.title = "Copy opportunity number";
|
||||
button.setAttribute("aria-label", `Copy ${label} ${optyNumber}`);
|
||||
button.title = `Copy ${label}`;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copyOpportunityNumber(String(optyNumber));
|
||||
copyOpportunityNumber(String(optyNumber), label, confirmationLabel);
|
||||
});
|
||||
|
||||
icon.setAttribute("viewBox", "0 0 24 24");
|
||||
@@ -47,7 +47,7 @@ function createOpportunitiesLinks({
|
||||
return button;
|
||||
}
|
||||
|
||||
async function copyOpportunityNumber(optyNumber) {
|
||||
async function copyOpportunityNumber(optyNumber, label = "opportunity number", confirmationLabel = "Opty Number") {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(optyNumber);
|
||||
@@ -63,9 +63,9 @@ function createOpportunitiesLinks({
|
||||
input.remove();
|
||||
}
|
||||
|
||||
showCopyConfirmation(`Opty Number ${optyNumber} copied.`);
|
||||
showCopyConfirmation(`${confirmationLabel} ${optyNumber} copied.`);
|
||||
} catch (error) {
|
||||
showCopyConfirmation("Unable to copy opportunity number.", true);
|
||||
showCopyConfirmation(`Unable to copy ${label}.`, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
/* 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 {
|
||||
flex: 0 0 auto;
|
||||
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="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-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="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 {
|
||||
display: grid;
|
||||
@@ -279,3 +290,24 @@
|
||||
position: static;
|
||||
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; }
|
||||
|
||||
@@ -11,6 +11,8 @@ function createOpportunitiesTable({
|
||||
createProductsCountButton,
|
||||
createProductsRampBadge,
|
||||
createCrmServiceRequestBadge,
|
||||
closeCrmSrPopover,
|
||||
createWorkloadActionsBadge,
|
||||
createProductsDetailRow,
|
||||
createStageBadge,
|
||||
createStatusBadge,
|
||||
@@ -51,6 +53,7 @@ function createOpportunitiesTable({
|
||||
return;
|
||||
}
|
||||
|
||||
closeCrmSrPopover();
|
||||
table.textContent = "";
|
||||
table.setAttribute("data-opportunities-layout", "inline-actions");
|
||||
table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false");
|
||||
@@ -137,6 +140,8 @@ function createOpportunitiesTable({
|
||||
}
|
||||
const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
|
||||
if (srBadge) productActions.append(srBadge);
|
||||
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
|
||||
if (actionsBadge) productActions.append(actionsBadge);
|
||||
nameCellContent.append(opportunityLink, productActions);
|
||||
cell.append(nameCellContent);
|
||||
} else if (column.key === "optyNumber" && item.OptyNumber) {
|
||||
|
||||
87
src/features/opportunities/workload-actions.js
Normal file
87
src/features/opportunities/workload-actions.js
Normal 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 };
|
||||
}
|
||||
@@ -7,10 +7,43 @@ function createProductsContentJobs({
|
||||
preferencesState,
|
||||
productsContentState,
|
||||
renderOpportunitiesTable,
|
||||
loadWorkloadActions,
|
||||
sendRuntimeMessage,
|
||||
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) {
|
||||
const optyNumbers = Array.from(new Set(opportunities
|
||||
.map((item) => item && item.OptyNumber)
|
||||
@@ -86,6 +119,7 @@ function createProductsContentJobs({
|
||||
cachedAt: Number(message.cachedAt) || 0,
|
||||
error: message.error || ""
|
||||
});
|
||||
loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber)));
|
||||
}
|
||||
|
||||
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
|
||||
@@ -130,6 +164,7 @@ function createProductsContentJobs({
|
||||
} else {
|
||||
badge.textContent = "Products ready 0";
|
||||
}
|
||||
updateCompletionVisibility(badge);
|
||||
|
||||
const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID);
|
||||
|
||||
|
||||
29
src/services/spa/workload-actions.js
Normal file
29
src/services/spa/workload-actions.js
Normal 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 };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ function createMessageTypes() {
|
||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||
|
||||
@@ -3,7 +3,7 @@ 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: {}, 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 });
|
||||
app.debugContentView.renderDebugPanel = () => {};
|
||||
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.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.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(null), null);
|
||||
app.opportunityCrmServiceRequests.reset();
|
||||
|
||||
105
tests/crmSrPopover.test.cjs
Normal file
105
tests/crmSrPopover.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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.opportunitiesTable.renderOpportunitiesTable();
|
||||
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();
|
||||
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
|
||||
});
|
||||
|
||||
109
tests/productsProgressFade.test.cjs
Normal file
109
tests/productsProgressFade.test.cjs
Normal 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);
|
||||
});
|
||||
185
tests/workloadActions.test.cjs
Normal file
185
tests/workloadActions.test.cjs
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user