Updated the process for retrieving records from CRMServiceRequests
This commit is contained in:
257
dist/chromium/content.js
vendored
257
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
257
dist/firefox/content.js
vendored
257
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -24,6 +24,7 @@ module.exports = {
|
|||||||
"src/features/opportunities/dashboard-state.js",
|
"src/features/opportunities/dashboard-state.js",
|
||||||
"src/features/debug/state.js",
|
"src/features/debug/state.js",
|
||||||
"src/features/opportunities/columns.js",
|
"src/features/opportunities/columns.js",
|
||||||
|
"src/features/opportunities/print.js",
|
||||||
"src/features/opportunities/badge-content.js",
|
"src/features/opportunities/badge-content.js",
|
||||||
"src/content/fusion-page.js",
|
"src/content/fusion-page.js",
|
||||||
"src/content/modal.js",
|
"src/content/modal.js",
|
||||||
|
|||||||
@@ -296,8 +296,8 @@ function createContentApplication(overrides = {}) {
|
|||||||
});
|
});
|
||||||
const internalSrSession = createInternalSrSession({
|
const internalSrSession = createInternalSrSession({
|
||||||
environment,
|
environment,
|
||||||
query: async srId => {
|
query: async (srId, srNumber) => {
|
||||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId, srNumber });
|
||||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -305,7 +305,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
const crmSrPopover = createCrmSrPopover({
|
const crmSrPopover = createCrmSrPopover({
|
||||||
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
|
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
|
||||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
requestDetails: srId => internalSrSession.request(srId),
|
requestDetails: (srId, srNumber) => internalSrSession.request(srId, srNumber),
|
||||||
MODAL_ID: contracts.MODAL_ID,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
||||||
@@ -364,7 +364,43 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
||||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
||||||
});
|
});
|
||||||
|
const opportunitiesPrint = createOpportunitiesPrint({ environment, getSnapshot: () => {
|
||||||
|
const state = opportunitiesTableState.opportunitiesTableState;
|
||||||
|
const items = opportunitiesFilters.getSortedOpportunities();
|
||||||
|
const filter = opportunitiesFilterState;
|
||||||
|
const selectedLabels = (options, selected, allLabel) => {
|
||||||
|
const matching = options.filter(option => selected.has(option.key));
|
||||||
|
if (options.length && matching.length === options.length) return allLabel;
|
||||||
|
return matching.map(option => option.label).join(", ") || "None";
|
||||||
|
};
|
||||||
|
const root = environment.document.getElementById(contracts.MODAL_ID);
|
||||||
|
const filters = [
|
||||||
|
["Reference", root?.querySelector(".opportunities-extension-form select")?.value || "-"],
|
||||||
|
["Customer", selectedLabels(opportunitiesFormatters.getCustomerOptions(), filter.selectedCustomerKeys, "All customers")],
|
||||||
|
["Owner", selectedLabels(opportunitiesFormatters.getOwnerOptions(), filter.selectedOwnerKeys, "All owners")],
|
||||||
|
["Search", filter.tableSearch || "All"],
|
||||||
|
["Status", [...filter.selectedStatuses].join(", ")],
|
||||||
|
["Stage", [...filter.selectedStages].join(", ")],
|
||||||
|
["Opportunity type", [...filter.selectedProductTypes].join(", ")]
|
||||||
|
];
|
||||||
|
for (const group of root?.querySelectorAll(".opportunities-extension-activity-filter-controls") || []) {
|
||||||
|
filters.push([group.getAttribute("aria-label").replace(/^Filter by /, ""),
|
||||||
|
[...group.querySelectorAll('[aria-pressed="true"]')].map(button => button.getAttribute("aria-label")).join("; ")]);
|
||||||
|
}
|
||||||
|
const totals = [{ label: "Total Opportunities", value: `${items.length}/${state.items.length}`, detail: "Filtered / total" }];
|
||||||
|
for (const stage of contracts.STAGE_OPTIONS) {
|
||||||
|
const stageItems = items.filter(item => item.ForecastGroup_c === stage);
|
||||||
|
const amount = stageItems.reduce((total, item) => {
|
||||||
|
const value = Number(item.PrimaryRevenue?.RevnAmount);
|
||||||
|
return total + (Number.isFinite(value) ? value : 0);
|
||||||
|
}, 0);
|
||||||
|
totals.push({ label: stage, value: opportunitiesDashboard.createStageAmountFormatter(stageItems)(amount), detail: `${stageItems.length} opportunities` });
|
||||||
|
}
|
||||||
|
return { status: state.status, filters, totals, columns,
|
||||||
|
rows: items.map(item => columns.map(column => opportunitiesFormatters.displayOpportunityValue(column, item))) };
|
||||||
|
} });
|
||||||
const opportunityCalendar = createOpportunityCalendar({
|
const opportunityCalendar = createOpportunityCalendar({
|
||||||
|
createPrintButton: () => opportunitiesPrint.createButton(),
|
||||||
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
|
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
|
||||||
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
|
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
|
||||||
createViewIcon: view => contentIcons.createViewIcon(view),
|
createViewIcon: view => contentIcons.createViewIcon(view),
|
||||||
|
|||||||
@@ -78,7 +78,18 @@
|
|||||||
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; }
|
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; }
|
||||||
.opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
|
.opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
|
||||||
/* Preserve room for Overview badges and the compact daily entry controls. */
|
/* Preserve room for Overview badges and the compact daily entry controls. */
|
||||||
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 38px; vertical-align: top; }
|
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 10px; vertical-align: top; }
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content > .opportunities-extension-cell-actions {
|
||||||
|
position: static;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; }
|
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; }
|
||||||
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
|
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
|
||||||
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }
|
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function createDebugContentView({
|
|||||||
if (entry.name === "requestOpptyProducts") return "products";
|
if (entry.name === "requestOpptyProducts") return "products";
|
||||||
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
||||||
if (entry.name === "requestInstallBase") return "installBase";
|
if (entry.name === "requestInstallBase") return "installBase";
|
||||||
if (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
|
if (["requestSRSearchResources", "requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||||
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
|
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||||
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
|
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
|
||||||
return "other";
|
return "other";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
|
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
|
||||||
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
|
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
|
||||||
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
|
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
|
||||||
productTypeSelection, onProductTypeChange }) {
|
productTypeSelection, onProductTypeChange, createPrintButton }) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
let elements = null;
|
let elements = null;
|
||||||
let calendar = null;
|
let calendar = null;
|
||||||
@@ -223,6 +223,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
|||||||
});
|
});
|
||||||
filters.append(button);
|
filters.append(button);
|
||||||
}
|
}
|
||||||
|
if (createPrintButton) filters.append(createPrintButton());
|
||||||
viewbar.append(filters);
|
viewbar.append(filters);
|
||||||
}
|
}
|
||||||
workspace.append(viewbar, tableSurface, surface); body.append(workspace);
|
workspace.append(viewbar, tableSurface, surface); body.append(workspace);
|
||||||
|
|||||||
@@ -124,9 +124,13 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
|||||||
children.setAttribute("role", "status");
|
children.setAttribute("role", "status");
|
||||||
children.textContent = "Loading SR details...";
|
children.textContent = "Loading SR details...";
|
||||||
section.append(children);
|
section.append(children);
|
||||||
detailLoads.push(async () => {
|
const loadDetails = async () => {
|
||||||
|
if (active !== entry || entry.panel !== panel) return;
|
||||||
|
children.textContent = "Loading SR details...";
|
||||||
|
children.setAttribute("aria-busy", "true");
|
||||||
|
children.setAttribute("role", "status");
|
||||||
try {
|
try {
|
||||||
const records = await requestDetails(item.SrId);
|
const records = await requestDetails(item.SrId, item.SrNumber);
|
||||||
if (active !== entry || entry.panel !== panel) return;
|
if (active !== entry || entry.panel !== panel) return;
|
||||||
children.textContent = "";
|
children.textContent = "";
|
||||||
if (!records.length) children.textContent = "No further details.";
|
if (!records.length) children.textContent = "No further details.";
|
||||||
@@ -157,13 +161,20 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
|||||||
if (active !== entry || entry.panel !== panel) return;
|
if (active !== entry || entry.panel !== panel) return;
|
||||||
children.setAttribute("role", "alert");
|
children.setAttribute("role", "alert");
|
||||||
children.textContent = error.message || "Unable to load SR details.";
|
children.textContent = error.message || "Unable to load SR details.";
|
||||||
|
const retry = document.createElement("button");
|
||||||
|
retry.type = "button";
|
||||||
|
retry.textContent = "Retry";
|
||||||
|
retry.setAttribute("aria-label", "Retry loading Internal Service Requests");
|
||||||
|
retry.addEventListener("click", () => { retry.disabled = true; loadDetails(); });
|
||||||
|
children.append(retry);
|
||||||
} finally {
|
} finally {
|
||||||
if (active === entry && entry.panel === panel) {
|
if (active === entry && entry.panel === panel) {
|
||||||
children.setAttribute("aria-busy", "false");
|
children.setAttribute("aria-busy", "false");
|
||||||
entry.position();
|
entry.position();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
detailLoads.push(loadDetails);
|
||||||
panel.append(section);
|
panel.append(section);
|
||||||
});
|
});
|
||||||
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
|
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
|
||||||
const { document, setTimeout } = environment;
|
const pending = new Map();
|
||||||
let recovery = null;
|
let recovery = null;
|
||||||
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
|
|
||||||
const now = () => environment.performance?.now() ?? Date.now();
|
|
||||||
function isUnauthorized(result) {
|
function isUnauthorized(result) {
|
||||||
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
|
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
|
||||||
if (status != null) return Number(status) === 401;
|
return status != null ? Number(status) === 401 : /failed with status 401\.$/.test(result?.error || "");
|
||||||
// Older background workers may return only the request error text.
|
|
||||||
return /failed with status 401\.$/.test(result?.error || "");
|
|
||||||
}
|
}
|
||||||
async function warmSession(id) {
|
async function warmSession(id, srNumber) {
|
||||||
|
const document = environment?.document;
|
||||||
|
if (!document?.body) throw new Error(`${sessionLabel} session cannot be initialized on this page.`);
|
||||||
const frame = document.createElement("iframe");
|
const frame = document.createElement("iframe");
|
||||||
|
// Restore the original session initialization route without opening a tab.
|
||||||
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
|
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
|
||||||
frame.title = `${sessionLabel} session initialization`;
|
frame.title = `${sessionLabel} session initialization`;
|
||||||
frame.tabIndex = -1;
|
frame.tabIndex = -1;
|
||||||
@@ -18,43 +17,46 @@ function createInternalSrSession({ query, environment, sessionUrl, sessionLabel
|
|||||||
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||||
frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;";
|
frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;";
|
||||||
let blocked = false;
|
let blocked = false;
|
||||||
let loaded = false;
|
const failed = () => { blocked = true; };
|
||||||
frame.addEventListener("error", () => { blocked = true; });
|
frame.addEventListener("error", failed);
|
||||||
frame.addEventListener("load", () => { loaded = true; });
|
|
||||||
try {
|
try {
|
||||||
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
|
|
||||||
document.body.append(frame);
|
document.body.append(frame);
|
||||||
const deadline = now() + 60000;
|
// Keep the iframe alive while its application completes authentication.
|
||||||
for (let attempt = 0; now() < deadline; attempt += 1) {
|
// A load event is not proof of a ready session. Limit and space API checks.
|
||||||
// Check immediately, then briefly poll faster before backing off.
|
for (const delay of [2000, 4000, 8000, 12000, 16000]) {
|
||||||
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now()));
|
await new Promise(resolve => environment.setTimeout(resolve, delay));
|
||||||
if (now() >= deadline) break;
|
if (blocked) throw new Error(`${sessionLabel} session iframe could not be loaded. Retry after signing in.`);
|
||||||
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`);
|
const result = await query(id, srNumber);
|
||||||
const result = await query(id);
|
if (result?.ok) return { id: String(id), result };
|
||||||
if (result?.ok) return { id, result };
|
|
||||||
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||||
}
|
}
|
||||||
throw new Error(`${sessionLabel} session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`);
|
throw new Error(`${sessionLabel} session is still unavailable. Retry loading the details after signing in.`);
|
||||||
} finally {
|
} finally {
|
||||||
// Stop remaining page resources without stopping the caller's page.
|
frame.removeEventListener("error", failed);
|
||||||
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ }
|
try { frame.contentWindow?.stop(); } catch { /* SSO may redirect cross-origin. */ }
|
||||||
frame.src = "about:blank";
|
|
||||||
frame.remove();
|
frame.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function request(id) {
|
function request(id, srNumber) {
|
||||||
let result = await query(id);
|
const key = String(id);
|
||||||
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) {
|
if (!pending.has(key)) {
|
||||||
|
const operation = (async () => {
|
||||||
|
let result = await query(id, srNumber);
|
||||||
|
if (!result?.ok && isUnauthorized(result)) {
|
||||||
if (!recovery) {
|
if (!recovery) {
|
||||||
recovery = warmSession(id);
|
const current = warmSession(id, srNumber);
|
||||||
const pending = recovery;
|
recovery = current;
|
||||||
pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {});
|
current.finally(() => { if (recovery === current) recovery = null; }).catch(() => {});
|
||||||
}
|
}
|
||||||
const recovered = await recovery;
|
const recovered = await recovery;
|
||||||
result = recovered.id === id ? recovered.result : await query(id);
|
result = recovered.id === key ? recovered.result : await query(id, srNumber);
|
||||||
}
|
}
|
||||||
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||||
return result.items;
|
return result.items;
|
||||||
|
})().finally(() => pending.delete(key));
|
||||||
|
pending.set(key, operation);
|
||||||
|
}
|
||||||
|
return pending.get(key);
|
||||||
}
|
}
|
||||||
return { request };
|
return { request };
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/features/opportunities/print.js
Normal file
100
src/features/opportunities/print.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
function createOpportunitiesPrint({ environment, getSnapshot }) {
|
||||||
|
function createButton() {
|
||||||
|
const doc = environment.document;
|
||||||
|
const button = doc.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-icon-button";
|
||||||
|
button.title = "Print opportunities";
|
||||||
|
button.setAttribute("aria-label", "Print opportunities");
|
||||||
|
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("fill", "none");
|
||||||
|
svg.setAttribute("stroke", "currentColor");
|
||||||
|
svg.setAttribute("stroke-width", "1.6");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
const path = doc.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
path.setAttribute("d", "M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6zM17 11h1");
|
||||||
|
svg.append(path); button.append(svg);
|
||||||
|
button.addEventListener("click", print);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
function print() {
|
||||||
|
const snapshot = getSnapshot();
|
||||||
|
if (!["ready", "empty"].includes(snapshot.status)) {
|
||||||
|
environment.window.alert("Wait for opportunities to finish loading before printing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const popup = environment.window.open("", "_blank", "width=1200,height=850");
|
||||||
|
if (!popup) { environment.window.alert("Allow pop-ups to print opportunities."); return; }
|
||||||
|
popup.opener = null;
|
||||||
|
const doc = popup.document;
|
||||||
|
doc.title = "Opportunities Extension — Report";
|
||||||
|
doc.documentElement.lang = "en";
|
||||||
|
const node = (tag, text, parent) => {
|
||||||
|
const el = doc.createElement(tag);
|
||||||
|
if (text != null) el.textContent = String(text);
|
||||||
|
if (parent) parent.append(el);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
node("style", `
|
||||||
|
@page { size: A4 landscape; margin: 15mm; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 32px; font: 11px/1.4 Arial, sans-serif; color: #222; background: white; }
|
||||||
|
h1 { margin: 0 0 4px; font-size: 21px; } h2 { font-size: 13px; margin: 16px 0 6px; }
|
||||||
|
.meta { color: #555; margin: 0 0 12px; }
|
||||||
|
.filters { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px 16px; border: 1px solid #ccc; padding: 10px; }
|
||||||
|
.filters div { overflow-wrap: anywhere; } .filters strong { margin-right: 5px; }
|
||||||
|
.totals { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 12px 0; }
|
||||||
|
.totals section { border: 1px solid #bbb; border-top: 3px solid #426a75; padding: 8px; break-inside: avoid; }
|
||||||
|
.totals strong { display: block; font-size: 16px; } .totals small { display: block; }
|
||||||
|
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 9px; }
|
||||||
|
th, td { padding: 6px 4px; border-bottom: 1px solid #ccc; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
||||||
|
th { background: #eee; font-weight: bold; } tbody tr:nth-child(even) { background: #f7f7f7; }
|
||||||
|
thead { display: table-header-group; } tr { break-inside: avoid; }
|
||||||
|
th:nth-child(1), th:nth-child(3) { width: 22%; }
|
||||||
|
th:nth-child(2) { width: 7%; } th:nth-child(4) { width: 8%; }
|
||||||
|
th:nth-child(5) { width: 9%; } th:nth-child(6) { width: 7%; }
|
||||||
|
th:nth-child(7), th:nth-child(8) { width: 5%; } th:nth-child(9) { width: 7%; } th:nth-child(10) { width: 8%; }
|
||||||
|
.money { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
|
button { padding: 8px 14px; cursor: pointer; }
|
||||||
|
@media print { body { margin: 0; } .toolbar { display: none; } th { print-color-adjust: exact; } }
|
||||||
|
`, doc.head);
|
||||||
|
const toolbar = node("div", null, doc.body); toolbar.className = "toolbar";
|
||||||
|
node("button", "Print / Save PDF", toolbar).addEventListener("click", () => popup.print());
|
||||||
|
node("button", "Close", toolbar).addEventListener("click", () => popup.close());
|
||||||
|
node("h1", "Opportunities Extension", doc.body);
|
||||||
|
node("p", `Generated ${new Date().toLocaleString("en-US")} • ${snapshot.rows.length} opportunities`, doc.body).className = "meta";
|
||||||
|
const filters = node("div", null, doc.body); filters.className = "filters";
|
||||||
|
for (const [label, value] of snapshot.filters) {
|
||||||
|
const field = node("div", null, filters);
|
||||||
|
node("strong", `${label}:`, field); node("span", value || "None", field);
|
||||||
|
}
|
||||||
|
const totals = node("div", null, doc.body); totals.className = "totals";
|
||||||
|
for (const card of snapshot.totals) {
|
||||||
|
const section = node("section", null, totals);
|
||||||
|
node("span", card.label, section); node("strong", card.value, section);
|
||||||
|
if (card.detail) node("small", card.detail, section);
|
||||||
|
}
|
||||||
|
node("h2", "Opportunities", doc.body);
|
||||||
|
const table = node("table", null, doc.body);
|
||||||
|
const header = node("tr", null, node("thead", null, table));
|
||||||
|
for (const column of snapshot.columns) {
|
||||||
|
const th = node("th", column.label, header); th.scope = "col";
|
||||||
|
if (column.key === "revenue") th.className = "money";
|
||||||
|
}
|
||||||
|
const body = node("tbody", null, table);
|
||||||
|
for (const values of snapshot.rows) {
|
||||||
|
const row = node("tr", null, body);
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
const td = node("td", value, row);
|
||||||
|
if (snapshot.columns[index].key === "revenue") td.className = "money";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!snapshot.rows.length) node("p", "No opportunities match the selected filters.", doc.body);
|
||||||
|
popup.focus();
|
||||||
|
// Print a frozen snapshot, without changing the extension's scroll or selected view.
|
||||||
|
environment.setTimeout(() => { if (!popup.closed) popup.print(); }, 150);
|
||||||
|
}
|
||||||
|
return { createButton, print };
|
||||||
|
}
|
||||||
@@ -2,10 +2,27 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
|||||||
logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug, maskToken, environment }) {
|
logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug, maskToken, environment }) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries";
|
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries";
|
||||||
function createPayload(offset) {
|
async function requestSRSearchResources(token) {
|
||||||
|
const searchUrl = `${url}?applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=CRMServiceRequest&language=en&limit=-1&onlyData=true`;
|
||||||
|
const headers = { Accept: "application/json", Authorization: `Bearer ${token}` };
|
||||||
|
const entry = logRequestStart({ name: "requestSRSearchResources", method: "GET", url: searchUrl, headers });
|
||||||
|
try {
|
||||||
|
const response = await fetch(searchUrl, { method: "GET", credentials: "include", headers });
|
||||||
|
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||||
|
if (response.status !== 200) throw new Error(`requestSRSearchResources failed with status ${response.status}.`);
|
||||||
|
const data = await response.json();
|
||||||
|
const number = data?.items?.[0]?.savedSearchNumber;
|
||||||
|
if (!Array.isArray(data?.items) || typeof number !== "string" || !number.trim()) {
|
||||||
|
throw new Error("requestSRSearchResources did not return savedSearchNumber in its first item.");
|
||||||
|
}
|
||||||
|
return number.trim();
|
||||||
|
} catch (error) { logRequestFailure(entry, error.message); throw error; }
|
||||||
|
}
|
||||||
|
function createPayload(offset, savedSearchNumber) {
|
||||||
|
if (typeof savedSearchNumber !== "string" || !savedSearchNumber.trim()) throw new Error("Missing SR savedSearchNumber.");
|
||||||
return {
|
return {
|
||||||
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true,
|
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true,
|
||||||
entity: "CRMServiceRequest", copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
|
entity: "CRMServiceRequest", copiedFrom: `queries/${savedSearchNumber.trim()}`,
|
||||||
q: { op: "$and", criteria: [
|
q: { op: "$and", criteria: [
|
||||||
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
|
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
|
||||||
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
||||||
@@ -19,11 +36,13 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
|
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
|
||||||
|
if (!isActive()) return null;
|
||||||
|
const savedSearchNumber = await requestSRSearchResources(token);
|
||||||
const items = [];
|
const items = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let attempt = 1;
|
let attempt = 1;
|
||||||
while (isActive()) {
|
while (isActive()) {
|
||||||
const payload = createPayload(offset);
|
const payload = createPayload(offset, savedSearchNumber);
|
||||||
const headers = {
|
const headers = {
|
||||||
Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`,
|
Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`,
|
||||||
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
|
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
|
||||||
@@ -60,5 +79,5 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { createPayload, requestCrmServiceRequest };
|
return { createPayload, requestCrmServiceRequest, requestSRSearchResources };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ const { loadApplication } = require("./support/application.cjs");
|
|||||||
const { response } = require("./support/characterization.cjs");
|
const { response } = require("./support/characterization.cjs");
|
||||||
|
|
||||||
function setup(fetch) {
|
function setup(fetch) {
|
||||||
const app = loadApplication("content", { fetch });
|
const app = loadApplication("content", { fetch: (url, options) => options.method === "GET"
|
||||||
|
? response({ items: [{ savedSearchNumber: "dynamic-sr-search" }] }) : fetch(url, options) });
|
||||||
app.currentUser.allowsCrmActivities = () => true;
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
app.opportunitiesQueryState.periodRequestVersion = 1;
|
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
app.debugContentView.renderDebugPanel = () => {};
|
app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ const assert = require("node:assert/strict");
|
|||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const { loadApplication } = require("./support/application.cjs");
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
const 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, searchFetch = async () => response({ items: [{ savedSearchNumber: "dynamic-sr-search" }] })) {
|
||||||
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
|
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
|
||||||
document.createElementNS = (_, tag) => document.createElement(tag);
|
document.createElementNS = (_, tag) => document.createElement(tag);
|
||||||
const app = loadApplication("content", { fetch, document, Intl, Date });
|
const app = loadApplication("content", { fetch: (url, options) => options.method === "GET" ? searchFetch(url, options) : fetch(url, options), document, Intl, Date });
|
||||||
app.currentUser.allowsCrmActivities = () => true;
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
app.debugContentView.renderDebugPanel = () => {};
|
app.debugContentView.renderDebugPanel = () => {};
|
||||||
app.opportunitiesQueryState.periodRequestVersion = 1;
|
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
@@ -15,6 +15,42 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
|
|||||||
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
|
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
|
||||||
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
|
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
|
||||||
|
|
||||||
|
test("SR saved search runs once before pages and uses the first item, not the default", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const app = setup(async (_url, options) => {
|
||||||
|
const payload = JSON.parse(options.body); calls.push(payload);
|
||||||
|
assert.equal(payload.copiedFrom, "queries/first-search");
|
||||||
|
return response({ items: [sr("A")], count: 1, offset: payload.offset, hasMore: payload.offset === 0 });
|
||||||
|
}, async (url, options) => {
|
||||||
|
assert.equal(options.method, "GET");
|
||||||
|
assert.equal(options.headers.Authorization, "Bearer synthetic");
|
||||||
|
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries?applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=CRMServiceRequest&language=en&limit=-1&onlyData=true");
|
||||||
|
calls.push("lookup");
|
||||||
|
return response({ items: [{ savedSearchNumber: "first-search" }, { savedSearchNumber: "other", isDefault: true }] });
|
||||||
|
});
|
||||||
|
await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1);
|
||||||
|
assert.equal(calls.length, 3); assert.equal(calls[0], "lookup");
|
||||||
|
assert.equal(app.debugContentView.getRequestCategory(app.debugState.requestLog[0]), "serviceRequests");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed or empty SR search blocks POST; superseded search never starts pagination", async () => {
|
||||||
|
for (const lookup of [
|
||||||
|
async () => new Response("Denied", { status: 401 }),
|
||||||
|
async () => response({ items: [] }),
|
||||||
|
async () => response({ items: [{ savedSearchNumber: " " }] }),
|
||||||
|
async () => new Response("<html>Login</html>")
|
||||||
|
]) {
|
||||||
|
const app = setup(() => assert.fail("No POST without a saved search"), lookup);
|
||||||
|
await assert.rejects(app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1));
|
||||||
|
assert.equal(app.debugState.requestLog[0].status, "failed");
|
||||||
|
}
|
||||||
|
const app = setup(() => assert.fail("Stale POST"), async () => {
|
||||||
|
app.opportunitiesQueryState.periodRequestVersion = 2;
|
||||||
|
return response({ items: [{ savedSearchNumber: "first" }] });
|
||||||
|
});
|
||||||
|
assert.equal(await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1), null);
|
||||||
|
});
|
||||||
|
|
||||||
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
|
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
|
||||||
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/A"], [[4, null], "N/A"], [[""], "N/A"], [["invalid"], "N/A"]]) {
|
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/A"], [[4, null], "N/A"], [[""], "N/A"], [["invalid"], "N/A"]]) {
|
||||||
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
|
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
|
||||||
@@ -79,7 +115,7 @@ test("CRM query preserves payload and pagination after opportunities, uses token
|
|||||||
assert.equal(options.headers.Preference, "transient");
|
assert.equal(options.headers.Preference, "transient");
|
||||||
assert.deepEqual(payload, {
|
assert.deepEqual(payload, {
|
||||||
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "CRMServiceRequest",
|
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "CRMServiceRequest",
|
||||||
copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
|
copiedFrom: "queries/dynamic-sr-search",
|
||||||
q: { op: "$and", criteria: [
|
q: { op: "$and", criteria: [
|
||||||
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
|
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
|
||||||
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
||||||
@@ -90,8 +126,9 @@ test("CRM query preserves payload and pagination after opportunities, uses token
|
|||||||
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"],
|
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"],
|
||||||
skipInValidFields: true, limit: 15, offset: 0
|
skipInValidFields: true, limit: 15, offset: 0
|
||||||
});
|
});
|
||||||
assert.equal(app.debugState.requestLog.length, 2);
|
assert.equal(app.debugState.requestLog.length, 3);
|
||||||
for (const log of app.debugState.requestLog) {
|
assert.equal(app.debugState.requestLog[0].name, "requestSRSearchResources");
|
||||||
|
for (const log of app.debugState.requestLog.slice(1)) {
|
||||||
assert.equal(log.name, "resquestCrmServiceRequest");
|
assert.equal(log.name, "resquestCrmServiceRequest");
|
||||||
assert.equal(log.requestHeaders.Origin, "https://eeho.fa.us2.oraclecloud.com");
|
assert.equal(log.requestHeaders.Origin, "https://eeho.fa.us2.oraclecloud.com");
|
||||||
assert.equal(log.requestHeaders.Preference, "transient");
|
assert.equal(log.requestHeaders.Preference, "transient");
|
||||||
@@ -144,6 +181,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
|
|||||||
let resolveOld;
|
let resolveOld;
|
||||||
const app = setup(() => new Promise(resolve => { resolveOld = resolve; }));
|
const app = setup(() => new Promise(resolve => { resolveOld = resolve; }));
|
||||||
const old = app.opportunityCrmServiceRequests.load("token", 1);
|
const old = app.opportunityCrmServiceRequests.load("token", 1);
|
||||||
|
while (!resolveOld) await new Promise(resolve => setImmediate(resolve));
|
||||||
app.opportunitiesQueryState.periodRequestVersion = 2;
|
app.opportunitiesQueryState.periodRequestVersion = 2;
|
||||||
app.opportunityCrmServiceRequests.reset();
|
app.opportunityCrmServiceRequests.reset();
|
||||||
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [sr("NEW")];
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [sr("NEW")];
|
||||||
@@ -152,7 +190,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
|
|||||||
await old;
|
await old;
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
|
||||||
assert.equal(app.debugState.requestLog.length, 1);
|
assert.equal(app.debugState.requestLog.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Reference changes share pending pagination and reuse completed CRM results", async () => {
|
test("Reference changes share pending pagination and reuse completed CRM results", async () => {
|
||||||
@@ -167,6 +205,7 @@ test("Reference changes share pending pagination and reuse completed CRM results
|
|||||||
app.opportunitiesQueryState.periodRequestVersion = 2;
|
app.opportunitiesQueryState.periodRequestVersion = 2;
|
||||||
const second = app.opportunityCrmServiceRequests.load("token", 2);
|
const second = app.opportunityCrmServiceRequests.load("token", 2);
|
||||||
assert.equal(first, second);
|
assert.equal(first, second);
|
||||||
|
while (!finish) await new Promise(resolve => setImmediate(resolve));
|
||||||
finish(response({ items: [sr("A")], count: 1, offset: 0, hasMore: true }));
|
finish(response({ items: [sr("A")], count: 1, offset: 0, hasMore: true }));
|
||||||
await second;
|
await second;
|
||||||
app.opportunitiesQueryState.periodRequestVersion = 3;
|
app.opportunitiesQueryState.periodRequestVersion = 3;
|
||||||
|
|||||||
@@ -250,44 +250,6 @@ test("Primary pillar lookup uses Session, orders enabled name/code options and r
|
|||||||
assert.equal((await loadBackground({ fetch, messageTimeout: 10000 }).dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" })).ok, false);
|
assert.equal((await loadBackground({ fetch, messageTimeout: 10000 }).dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" })).ok, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
for (const family of ["chrome", "firefox"]) {
|
|
||||||
test(`${family}: 401 is returned to the caller without opening tabs`, async () => {
|
|
||||||
let count = 0;
|
|
||||||
const h = loadBackground({ family, fetch: async () => ++count < 3 ? response({}, 401) : response({ items: [] }) });
|
|
||||||
const result = await h.dispatch({ type, srId: "123" });
|
|
||||||
assert.equal(result.ok, false);
|
|
||||||
assert.equal(result.httpStatus, 401);
|
|
||||||
assert.equal(count, 1);
|
|
||||||
assert.equal(h.tabs.length, 0);
|
|
||||||
assert.deepEqual(h.removedTabs, []);
|
|
||||||
assert.deepEqual(result.debugRequests.map(entry => entry.httpStatus), [401]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
test("concurrent 401 responses do not cause background tab recovery", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const h = loadBackground({ fetch: async url => {
|
|
||||||
if (++calls <= 2) return response({}, 401);
|
|
||||||
const id = new URL(url).searchParams.get("q");
|
|
||||||
return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: [{ Title: id }] }] });
|
|
||||||
} });
|
|
||||||
const results = await Promise.all([h.dispatch({ type, srId: "1" }), h.dispatch({ type, srId: "2" })]);
|
|
||||||
assert.equal(h.tabs.length, 0);
|
|
||||||
assert.deepEqual(h.removedTabs, []);
|
|
||||||
assert.ok(results.every(result => result.httpStatus === 401));
|
|
||||||
});
|
|
||||||
test("background never creates recovery tabs after an error", async () => {
|
|
||||||
for (const status of [401, 500]) {
|
|
||||||
let count = 0;
|
|
||||||
const h = loadBackground({ fetch: async () => response({}, ++count === 1 ? 401 : status) });
|
|
||||||
const result = await h.dispatch({ type, srId: "1" });
|
|
||||||
assert.equal(result.ok, false);
|
|
||||||
assert.equal(count, 1);
|
|
||||||
assert.deepEqual(h.removedTabs, []);
|
|
||||||
}
|
|
||||||
const h = loadBackground({ fetch: async () => response({}, 403) });
|
|
||||||
assert.equal((await h.dispatch({ type, srId: "1" })).ok, false);
|
|
||||||
assert.equal(h.tabs.length, 0);
|
|
||||||
});
|
|
||||||
for (const family of ["chrome", "firefox"]) {
|
for (const family of ["chrome", "firefox"]) {
|
||||||
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
|
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ test("internal SR form validates Save, maps parent data, prevents duplicate post
|
|||||||
assert.ok(!h.walk(h.modal).includes(form));
|
assert.ok(!h.walk(h.modal).includes(form));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("internal SR uses Session without TokenRelay and reports unavailable iframe host", async () => {
|
test("internal SR uses Session and offers manual retry after 401 without automatic probes", async () => {
|
||||||
const h = setup();
|
const h = setup();
|
||||||
let renewals = 0;
|
let renewals = 0;
|
||||||
const sent = [];
|
const sent = [];
|
||||||
@@ -106,6 +106,11 @@ test("internal SR uses Session without TokenRelay and reports unavailable iframe
|
|||||||
assert.equal(sent[0].token, undefined);
|
assert.equal(sent[0].token, undefined);
|
||||||
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
|
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
|
||||||
assert.ok(h.walk(h.panel()).some(node => node.attributes.role === "alert"));
|
assert.ok(h.walk(h.panel()).some(node => node.attributes.role === "alert"));
|
||||||
|
const retry = h.walk(h.panel()).find(node => node.attributes["aria-label"] === "Retry loading Internal Service Requests");
|
||||||
|
assert.ok(retry);
|
||||||
|
retry.listeners.click();
|
||||||
|
await flush();
|
||||||
|
assert.equal(sent.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
|
|||||||
14
tests/internalSrRecovery.test.cjs
Normal file
14
tests/internalSrRecovery.test.cjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const factory = vm.runInNewContext(fs.readFileSync('src/services/fusion/sr-details.js', 'utf8') + '\ncreateFusionSrDetails');
|
||||||
|
test('Internal SR background returns 401 without opening any tab or retrying', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const api = factory({
|
||||||
|
fetchJson: async () => { calls++; throw Object.assign(new Error('Unauthorized'), {httpStatus:401}); },
|
||||||
|
tabsCreate: () => assert.fail('Internal SR must not open a tab')
|
||||||
|
});
|
||||||
|
await assert.rejects(api.requestInternalServiceRequest({srId:'123'}), /Unauthorized/);
|
||||||
|
assert.equal(calls,1);
|
||||||
|
});
|
||||||
@@ -1,145 +1,69 @@
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require('node:assert/strict');
|
||||||
const { test } = require("node:test");
|
const { test } = require('node:test');
|
||||||
const fs = require("node:fs");
|
const fs = require('node:fs');
|
||||||
const vm = require("node:vm");
|
const vm = require('node:vm');
|
||||||
const path = require("node:path");
|
const factory = vm.runInNewContext(fs.readFileSync('src/features/opportunities/internal-sr-session.js', 'utf8') + '\ncreateInternalSrSession');
|
||||||
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/internal-sr-session.js"), "utf8")}\ncreateInternalSrSession`);
|
test('without a document, session recovery stops without polling', async () => {
|
||||||
function setup(query, options = {}) {
|
for (const result of [{ok:false,httpStatus:401}, {ok:false,debugRequests:[{httpStatus:401}]}, {ok:false,error:'Request failed with status 401.'}]) {
|
||||||
let elapsed = 0;
|
let calls=0;
|
||||||
const frames = [], removed = [], delays = [], stopped = [];
|
const service=factory({query:async()=>{calls++; return result;},environment:{document:{createElement(){assert.fail('No iframe');}},setTimeout(){assert.fail('No polling');}}});
|
||||||
const body = { append(frame) { frame.initialSrc = frame.src; frames.push(frame); } };
|
await assert.rejects(service.request('123'), /Internal SR|status 401/);
|
||||||
const document = { body, createElement(tag) {
|
await new Promise(resolve=>setImmediate(resolve));
|
||||||
assert.equal(tag, "iframe");
|
assert.equal(calls,1);
|
||||||
return { contentWindow: { stop() { stopped.push(true); } }, style: {}, attributes: {}, listeners: {}, setAttribute(k, v) { this.attributes[k] = v; },
|
}
|
||||||
addEventListener(k, fn) { this.listeners[k] = fn; }, remove() { removed.push(this); } };
|
});
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const timers = [], listeners = new Map();
|
||||||
|
let appended = 0, removed = 0;
|
||||||
|
const frame = {style:{},setAttribute(){},addEventListener(k,v){listeners.set(k,v);},removeEventListener(k){listeners.delete(k);},remove(){removed++;}};
|
||||||
|
return { timers, frame, listeners, counts:()=>({appended,removed}), environment:{
|
||||||
|
document:{createElement:()=>frame,body:{append(){appended++;}}},
|
||||||
|
setTimeout(fn,delay){timers.push({fn,delay});}
|
||||||
}};
|
}};
|
||||||
const api = factory({ ...options, query, environment: { document, performance: { now: () => elapsed }, setTimeout: (fn, ms) => { delays.push(ms); elapsed += ms; return setImmediate(fn); } } });
|
|
||||||
return { api, frames, removed, delays, stopped };
|
|
||||||
}
|
}
|
||||||
|
const flush = () => new Promise(resolve=>setImmediate(resolve));
|
||||||
test("lookup requests recover together using the configured page and preserve each result", async () => {
|
test('hidden iframe remains alive after load and closes only after successful session query',async()=>{
|
||||||
let authenticated = false;
|
const h=harness(); let calls=0;
|
||||||
let calls = 0;
|
const api=factory({environment:h.environment,query:async()=>++calls<3?{ok:false,httpStatus:401}:{ok:true,items:[{SrId:1}]}});
|
||||||
const h = setup(async message => {
|
const first=api.request('123'); assert.equal(first,api.request(123));
|
||||||
if (++calls <= 4) return { ok: false, httpStatus: 401 };
|
await flush();
|
||||||
authenticated = true;
|
assert.equal(h.counts().appended,1);
|
||||||
return { ok: true, items: [message.type] };
|
h.listeners.get('load')?.();
|
||||||
}, { sessionUrl: "https://eeho.fa.us2.oraclecloud.com/?view=Show%20Service%20Request", sessionLabel: "Service Request" });
|
assert.equal(h.counts().removed,0);
|
||||||
const types = ["pillar", "language", "platform", "services"];
|
assert.equal(calls,1);
|
||||||
const results = await Promise.all(types.map(type => h.api.request({ type })));
|
assert.equal(h.timers[0].delay,2000);
|
||||||
assert.deepEqual(results, types.map(type => [type]));
|
h.timers.shift().fn(); await flush();
|
||||||
assert.equal(authenticated, true);
|
assert.equal(h.counts().removed,0);
|
||||||
assert.equal(h.frames.length, 1);
|
assert.equal(h.timers[0].delay,4000);
|
||||||
assert.match(h.frames[0].initialSrc, /Show%20Service%20Request$/);
|
h.timers.shift().fn();
|
||||||
assert.equal(h.frames[0].title, "Service Request session initialization");
|
assert.equal((await first)[0].SrId,1);
|
||||||
assert.equal(h.removed.length, 1);
|
assert.equal(h.counts().removed,1);
|
||||||
assert.equal(h.stopped.length, 1);
|
|
||||||
});
|
});
|
||||||
test("401 initializes a hidden iframe in the calling document and removes it on success", async () => {
|
test('persistent 401 is bounded to five recovery checks and removes iframe',async()=>{
|
||||||
let calls = 0;
|
const h=harness(); let calls=0;
|
||||||
const h = setup(async () => ++calls < 3 ? { ok: false, httpStatus: 401 } : { ok: true, items: [{ SrId: 1 }] });
|
const api=factory({environment:h.environment,query:async()=>{calls++;return {ok:false,httpStatus:401};}});
|
||||||
const items = await h.api.request("1");
|
const failed=assert.rejects(api.request('123'),/still unavailable/);
|
||||||
assert.equal(items[0].SrId, 1);
|
for(const delay of [2000,4000,8000,12000,16000]) {
|
||||||
assert.equal(h.frames.length, 1);
|
await flush(); assert.equal(h.timers[0].delay,delay); h.timers.shift().fn();
|
||||||
const frame = h.frames[0];
|
|
||||||
assert.equal(frame.tabIndex, -1);
|
|
||||||
assert.equal(frame.attributes["aria-hidden"], "true");
|
|
||||||
assert.match(frame.style.cssText, /pointer-events:none/);
|
|
||||||
assert.ok(!frame.attributes.sandbox.includes("allow-top-navigation"));
|
|
||||||
assert.ok(frame.initialSrc.endsWith("&view=showisr"));
|
|
||||||
assert.equal(frame.src, "about:blank");
|
|
||||||
assert.equal(h.stopped.length, 1);
|
|
||||||
assert.deepEqual(h.delays, [250]);
|
|
||||||
assert.equal(h.removed[0], frame);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an immediately available session stops the iframe without waiting for load or a timer", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const h = setup(async () => ++calls === 1 ? { ok: false, httpStatus: 401 } : { ok: true, items: [] });
|
|
||||||
await h.api.request("1");
|
|
||||||
assert.equal(calls, 2);
|
|
||||||
assert.equal(h.delays.length, 0);
|
|
||||||
assert.equal(h.stopped.length, 1);
|
|
||||||
assert.equal(h.removed.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lookup 401 errors from older background responses still initialize the session", async () => {
|
|
||||||
for (const failure of [
|
|
||||||
{ ok: false, error: "requestSrLookupLanguage failed with status 401." },
|
|
||||||
{ ok: false, debugRequests: [{ httpStatus: 401 }] },
|
|
||||||
{ ok: false, httpStatus: "401" }
|
|
||||||
]) {
|
|
||||||
let calls = 0;
|
|
||||||
const h = setup(async () => ++calls <= 2 ? failure : { ok: true, items: ["ready"] });
|
|
||||||
assert.deepEqual(await h.api.request("language"), ["ready"]);
|
|
||||||
assert.equal(h.frames.length, 1);
|
|
||||||
assert.equal(h.removed.length, 1);
|
|
||||||
}
|
}
|
||||||
|
await failed;
|
||||||
|
assert.equal(calls,6);
|
||||||
|
assert.equal(h.counts().removed,1);
|
||||||
|
assert.equal(h.timers.length,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a lookup that still returns 401 after shared recovery initializes its own session", async () => {
|
test('concurrent requests share work and explicit retry can recover', async () => {
|
||||||
const calls = new Map();
|
let calls=0, complete;
|
||||||
const h = setup(async id => {
|
const service=factory({query:()=>{calls++; return new Promise(resolve=>{complete=resolve;});}});
|
||||||
const count = (calls.get(id) || 0) + 1;
|
const first=service.request('123'); const second=service.request(123);
|
||||||
calls.set(id, count);
|
assert.equal(first,second); await Promise.resolve();
|
||||||
return count <= (id === "language" ? 1 : 2)
|
complete({ok:false,httpStatus:401}); await assert.rejects(first);
|
||||||
? { ok: false, httpStatus: 401 } : { ok: true, items: [id] };
|
const retry=service.request('123'); await Promise.resolve(); complete({ok:true,items:[{SrId:'456'}]});
|
||||||
|
assert.equal((await retry)[0].SrId,'456'); assert.equal(calls,2);
|
||||||
});
|
});
|
||||||
assert.deepEqual(await Promise.all([h.api.request("language"), h.api.request("platform")]), [["language"], ["platform"]]);
|
test('other errors do not retry and successful empty results are preserved', async()=>{
|
||||||
assert.equal(h.frames.length, 2);
|
const failed=factory({query:async()=>({ok:false,error:'Forbidden',httpStatus:403})});
|
||||||
assert.equal(h.removed.length, 2);
|
await assert.rejects(failed.request('123'),/Forbidden/);
|
||||||
});
|
const ok=factory({query:async()=>({ok:true,items:[]})}); assert.equal((await ok.request('123')).length,0);
|
||||||
|
|
||||||
test("cross-origin redirects still allow iframe removal when stop is blocked", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const h = setup(async () => {
|
|
||||||
if (++calls === 1) return { ok: false, httpStatus: 401 };
|
|
||||||
h.frames[0].contentWindow.stop = () => { throw new Error("Cross-origin"); };
|
|
||||||
return { ok: true, items: [] };
|
|
||||||
});
|
|
||||||
await h.api.request("1");
|
|
||||||
assert.equal(h.frames[0].src, "about:blank");
|
|
||||||
assert.equal(h.removed.length, 1);
|
|
||||||
});
|
|
||||||
test("concurrent queries share one iframe without mixing SR results", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const h = setup(async id => ++calls <= 2 ? { ok: false, httpStatus: 401 } : { ok: true, items: [id] });
|
|
||||||
const results = await Promise.all([h.api.request("1"), h.api.request("2")]);
|
|
||||||
assert.deepEqual(results, [["1"], ["2"]]);
|
|
||||||
assert.equal(h.frames.length, 1);
|
|
||||||
assert.equal(h.removed.length, 1);
|
|
||||||
});
|
|
||||||
test("normal success does not mount an iframe; errors and timeout always clean it up", async () => {
|
|
||||||
const success = setup(async () => ({ ok: true, items: [] }));
|
|
||||||
await success.api.request("1");
|
|
||||||
assert.equal(success.frames.length, 0);
|
|
||||||
for (const status of [401, 500]) {
|
|
||||||
let calls = 0;
|
|
||||||
const h = setup(async () => ({ ok: false, httpStatus: ++calls === 1 ? 401 : status }));
|
|
||||||
await assert.rejects(h.api.request("1"));
|
|
||||||
if (status === 401) {
|
|
||||||
assert.ok(calls > 31);
|
|
||||||
assert.equal(h.delays.reduce((sum, ms) => sum + ms, 0), 60000);
|
|
||||||
} else assert.equal(calls, 2);
|
|
||||||
assert.equal(h.removed.length, 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("slow session initialization can succeed after the old thirty-attempt limit", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const h = setup(async () => ++calls < 45 ? { ok: false, httpStatus: 401 } : { ok: true, items: ["ready"] });
|
|
||||||
assert.deepEqual(await h.api.request("1"), ["ready"]);
|
|
||||||
assert.equal(calls, 45);
|
|
||||||
assert.equal(h.removed.length, 1);
|
|
||||||
assert.ok(h.delays.reduce((sum, ms) => sum + ms, 0) < 60000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("iframe load alone never confirms authentication", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const h = setup(async () => {
|
|
||||||
if (++calls > 1) h.frames[0].listeners.load();
|
|
||||||
return { ok: false, httpStatus: 401 };
|
|
||||||
});
|
|
||||||
await assert.rejects(h.api.request("1"), /HTTP 401.*load event, but authentication was not confirmed/);
|
|
||||||
assert.equal(h.removed.length, 1);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user