6 Commits

54 changed files with 2503 additions and 537 deletions

View File

@@ -1,4 +1,4 @@
# Opportunities Extension 1.0.7 # Opportunities Extension
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar. Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar.
@@ -316,15 +316,15 @@ scrolling is reserved for table containers below
1120px. The scoped table stylesheet does not change product-table column widths 1120px. The scoped table stylesheet does not change product-table column widths
or the existing light/dark colors. or the existing light/dark colors.
Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start. Product HTTP requests appear in Debug Requests (Ctrl+Shift+D) as soon as they start.
Each entry identifies the opportunity and page offset, stays `running` while Each entry identifies the opportunity and page offset, stays `running` while
waiting for headers or the response body, and updates with HTTP status, response, waiting for headers or the response body, and updates with HTTP status, response,
duration or error. Cache hits do not create HTTP entries. Product diagnostics duration or error. Cache hits do not create HTTP entries. Product diagnostics
mask authorization and sensitive response headers before messaging the source mask authorization and sensitive response headers before messaging the source
tab/frame; this change adds no timeout, retry or authentication behavior. tab/frame; this change adds no timeout, retry or authentication behavior.
Debug Requests opens as a full-page view inside the extension using Ctrl+D, without Debug Requests opens as a full-page view inside the extension using Ctrl+Shift+D, without
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+D opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+Shift+D
returns to the underlying view. Header/category navigation remains above the returns to the underlying view. Header/category navigation remains above the
scrollable timeline; expanded details use two columns on wide screens and one on scrollable timeline; expanded details use two columns on wide screens and one on
narrow screens. Theme-specific colors distinguish success, failure, running, narrow screens. Theme-specific colors distinguish success, failure, running,
@@ -372,94 +372,6 @@ AI text and consumption; unexpected requests fail closed. It does not authentica
against Fusion or read real extension storage. `/consumption-trend.html` provides against Fusion or read real extension storage. `/consumption-trend.html` provides
a focused chart fixture. Stop the server with Ctrl+C when finished. a focused chart fixture. Stop the server with Ctrl+C when finished.
## Release 1.0.0
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
- Opportunity type view preferences persisted in the browser.
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
- Light and dark themes based on Oracle Redwood.
- Separate outputs for Chromium/Edge and Firefox.
## Release 1.0.1
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
## Release 1.0.2
- Modularized the extension with regression tests and documented execution boundaries.
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
## Release 1.0.3 - 2026-10-13
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
- Added searchable Action forms, required-field validation, saving and local badge updates.
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
## Release 1.0.4 - 2026-10-19
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.5 - 2026-10-20
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.6 - 2026-10-21
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
## Release 1.0.7 - 2026-10-26
- Remember table sorting preferences and restore the selected column and direction when reopening supported tables.
- Retrieve the user's Opportunity saved searches before loading opportunities and use the default savedSearchNumber for copiedFrom across all result pages.
- Show requestOpportunitiesSavedSearch in the Opportunities debug category and stop dependent queries when the default search is missing or invalid.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests were not performed for this release.
## Release 1.0.8 - 2026-10-27
- Added customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
- Added paginated, sortable opportunities by contact using the contact PartyNumber.
- Unified modal and table layouts, loading skeletons and typography; added Rate Card order-number filtering and simplified Overview customer names using PartyName.
- Added Reference-bounded scrolling calendars with all daily opportunity cards and positioning around the current week.
- Added Booking/Workload filters and improved ramp/projection access, alerts and unavailable-workload indicators.
- Removed AI-generated opportunity summaries and their dedicated requests.
- Persisted debug requests in localStorage with lazy rendering; history resets only when a new opportunity query starts.
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support.
- Prepare SPA and SR sessions concurrently, inspect SPA before visual assets finish loading, and provide SR session retry with a 70-second timeout.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests are not included.
## Loading in Development Mode ## Loading in Development Mode
### Chrome or Microsoft Edge ### Chrome or Microsoft Edge
@@ -481,3 +393,5 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome` - `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface. When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.
With the Opportunities Extension modal open, press Ctrl+Shift+F to choose a browser bookmarks folder and save the extension shortcut.

107
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,107 @@
# Release Notes
## Release 1.1.0 - 2026-10-30
- Display opportunities first, then load Service Requests and update their badges in the background.
- Clicking an item when all filter choices are selected now selects that item alone, including Booking and Workload.
- Added a bookmark shortcut that opens the extension automatically from Fusion.
- Ctrl+Shift+F opens a searchable bookmarks folder picker; Ctrl+Shift+D toggles Debug Requests.
- Show a prominent favorites shortcut hint for 20 seconds when the shortcut is not bookmarked; keep Last update first in the header.
- Smaller dialogs can be dragged by their title bars, with up to 70% overflow beyond viewport edges.
- Updated Chromium/Edge and Firefox builds. Automated validation uses synthetic data; live Oracle and installed-browser testing are not included.
## Release 1.0.9 - 2026-10-28
- Added a landscape print/PDF view with filters, totals, and opportunities.
- Resolve the CRM Service Request saved search dynamically before querying records.
- Restore hidden-iframe Internal SR session recovery with bounded retries and shared pending requests.
- Align opportunity badges and customer actions consistently.
- Move release history from README to this dedicated file.
## Release 1.0.8 - 2026-10-27
- Added customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
- Added paginated, sortable opportunities by contact using the contact PartyNumber.
- Unified modal and table layouts, loading skeletons and typography; added Rate Card order-number filtering and simplified Overview customer names using PartyName.
- Added Reference-bounded scrolling calendars with all daily opportunity cards and positioning around the current week.
- Added Booking/Workload filters and improved ramp/projection access, alerts and unavailable-workload indicators.
- Removed AI-generated opportunity summaries and their dedicated requests.
- Persisted debug requests in localStorage with lazy rendering; history resets only when a new opportunity query starts.
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support.
- Prepare SPA and SR sessions concurrently, inspect SPA before visual assets finish loading, and provide SR session retry with a 70-second timeout.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests are not included.
## Release 1.0.7 - 2026-10-26
- Remember table sorting preferences and restore the selected column and direction when reopening supported tables.
- Retrieve the user's Opportunity saved searches before loading opportunities and use the default savedSearchNumber for copiedFrom across all result pages.
- Show requestOpportunitiesSavedSearch in the Opportunities debug category and stop dependent queries when the default search is missing or invalid.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests were not performed for this release.
## Release 1.0.6 - 2026-10-21
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
## Release 1.0.5 - 2026-10-20
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.4 - 2026-10-19
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.3 - 2026-10-13
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
- Added searchable Action forms, required-field validation, saving and local badge updates.
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
## Release 1.0.2
- Modularized the extension with regression tests and documented execution boundaries.
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
## Release 1.0.1
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
## Release 1.0.0
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
- Opportunity type view preferences persisted in the browser.
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
- Light and dark themes based on Oracle Redwood.
- Separate outputs for Chromium/Edge and Firefox.

View File

@@ -292,6 +292,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
/** Stable wire names. All payloads remain plain, serializable objects. */ /** Stable wire names. All payloads remain plain, serializable objects. */
function createMessageTypes() { function createMessageTypes() {
return Object.freeze({ return Object.freeze({
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart", loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart", saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
requestWeekDetails: "opportunitiesExtension.requestWeekDetails", requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
@@ -3137,10 +3140,52 @@ function registerBackgroundMessages({
requestOpptyProducts, requestOpptyProducts,
runtimeApi runtimeApi
}) { }) {
const bookmarkPending = new Map();
async function getBookmarkFolders() {
const folders = [];
function visit(nodes, ancestors = []) {
for (const node of nodes) {
if (node.url || node.unmodifiable) continue;
const path = node.parentId ? [...ancestors, node.title] : ancestors;
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
visit(node.children || [], path);
}
}
visit(await runtimeApi.bookmarks.getTree());
return { ok: true, folders };
}
function addShortcutBookmark(parentId) {
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
const pending = (async () => {
const { folders } = await getBookmarkFolders();
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
const existing = await runtimeApi.bookmarks.search({ url });
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
return { ok: true, existing: false };
})().finally(() => { bookmarkPending.delete(parentId); });
bookmarkPending.set(parentId, pending);
return pending;
}
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) { if (!message || !message.type) {
return false; return false;
} }
if (message.type === messageTypes.addShortcutBookmark) {
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getBookmarkFolders) {
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getShortcutBookmarkStatus) {
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
.catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) { if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart; const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message })); operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,9 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Opportunities Extension", "name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", "description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.8", "version": "1.1.0",
"permissions": [ "permissions": [
"bookmarks",
"cookies", "cookies",
"storage", "storage",
"webRequest", "webRequest",
@@ -15,6 +16,17 @@
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*" "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
], ],
"content_scripts": [ "content_scripts": [
{
"matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
],
"js": [
"shortcut-intent.js"
],
"all_frames": false,
"run_at": "document_start"
},
{ {
"matches": [ "matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",

11
dist/chromium/shortcut-intent.js vendored Normal file
View File

@@ -0,0 +1,11 @@
// Capture the bookmark before Fusion rewrites its navigation URL.
(() => {
if (window.top !== window) return;
const capture = () => {
const url = new URL(window.location.href);
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
};
capture();
window.addEventListener("hashchange", capture);
})();

View File

@@ -292,6 +292,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
/** Stable wire names. All payloads remain plain, serializable objects. */ /** Stable wire names. All payloads remain plain, serializable objects. */
function createMessageTypes() { function createMessageTypes() {
return Object.freeze({ return Object.freeze({
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart", loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart", saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
requestWeekDetails: "opportunitiesExtension.requestWeekDetails", requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
@@ -3137,10 +3140,52 @@ function registerBackgroundMessages({
requestOpptyProducts, requestOpptyProducts,
runtimeApi runtimeApi
}) { }) {
const bookmarkPending = new Map();
async function getBookmarkFolders() {
const folders = [];
function visit(nodes, ancestors = []) {
for (const node of nodes) {
if (node.url || node.unmodifiable) continue;
const path = node.parentId ? [...ancestors, node.title] : ancestors;
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
visit(node.children || [], path);
}
}
visit(await runtimeApi.bookmarks.getTree());
return { ok: true, folders };
}
function addShortcutBookmark(parentId) {
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
const pending = (async () => {
const { folders } = await getBookmarkFolders();
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
const existing = await runtimeApi.bookmarks.search({ url });
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
return { ok: true, existing: false };
})().finally(() => { bookmarkPending.delete(parentId); });
bookmarkPending.set(parentId, pending);
return pending;
}
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) { if (!message || !message.type) {
return false; return false;
} }
if (message.type === messageTypes.addShortcutBookmark) {
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getBookmarkFolders) {
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getShortcutBookmarkStatus) {
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
.catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) { if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart; const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message })); operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,9 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Opportunities Extension", "name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", "description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.8", "version": "1.1.0",
"permissions": [ "permissions": [
"bookmarks",
"cookies", "cookies",
"storage", "storage",
"webRequest", "webRequest",
@@ -15,6 +16,17 @@
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*" "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
], ],
"content_scripts": [ "content_scripts": [
{
"matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
],
"js": [
"shortcut-intent.js"
],
"all_frames": false,
"run_at": "document_start"
},
{ {
"matches": [ "matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",

11
dist/firefox/shortcut-intent.js vendored Normal file
View File

@@ -0,0 +1,11 @@
// Capture the bookmark before Fusion rewrites its navigation URL.
(() => {
if (window.top !== window) return;
const capture = () => {
const url = new URL(window.location.href);
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
};
capture();
window.addEventListener("hashchange", capture);
})();

View File

@@ -1,6 +1,6 @@
{ {
"name": "opportunities-extension", "name": "opportunities-extension",
"version": "1.0.8", "version": "1.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -9,14 +9,16 @@ const distDir = path.join(rootDir, "dist");
const contentScript = composition.composeScript("content"); const contentScript = composition.composeScript("content");
const backgroundScript = composition.composeScript("background"); const backgroundScript = composition.composeScript("background");
const spaPageScript = composition.composeScript("spa-page"); const spaPageScript = composition.composeScript("spa-page");
const shortcutScript = await readFile(path.join(rootDir, "src/content/shortcut-intent.js"), "utf8");
const checkOnly = process.argv.includes("--check"); const checkOnly = process.argv.includes("--check");
const baseManifest = { const baseManifest = {
manifest_version: 3, manifest_version: 3,
name: "Opportunities Extension", name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.8", version: "1.1.0",
permissions: [ permissions: [
"bookmarks",
"cookies", "cookies",
"storage", "storage",
"webRequest", "webRequest",
@@ -28,6 +30,12 @@ const baseManifest = {
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*" "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
], ],
content_scripts: [ content_scripts: [
{
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
js: ["shortcut-intent.js"],
all_frames: false,
run_at: "document_start"
},
{ {
matches: [ matches: [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
@@ -77,7 +85,7 @@ for (const target of targets) {
JSON.parse(manifestJson); JSON.parse(manifestJson);
if (checkOnly) { if (checkOnly) {
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript }; const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript, "shortcut-intent.js": shortcutScript };
for (const [file, contents] of Object.entries(expected)) { for (const [file, contents] of Object.entries(expected)) {
const actual = await readFile(path.join(distDir, target.name, file), "utf8"); const actual = await readFile(path.join(distDir, target.name, file), "utf8");
if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`); if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
@@ -90,6 +98,7 @@ for (const target of targets) {
await output.writeOutput(path.join(outputDir, "content.js"), contentScript); await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript); await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
await output.writeOutput(path.join(outputDir, "spa-page.js"), spaPageScript); await output.writeOutput(path.join(outputDir, "spa-page.js"), spaPageScript);
await output.writeOutput(path.join(outputDir, "shortcut-intent.js"), shortcutScript);
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson); await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
} }

View File

@@ -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",
@@ -31,6 +32,7 @@ module.exports = {
"src/content/icons.js", "src/content/icons.js",
"src/content/svg.js", "src/content/svg.js",
"src/content/styles.js", "src/content/styles.js",
"src/content/dialog-drag.js",
"src/auth/content-session.js", "src/auth/content-session.js",
"src/auth/current-user.js", "src/auth/current-user.js",
"src/auth/token-relay.js", "src/auth/token-relay.js",

View File

@@ -44,10 +44,52 @@ function registerBackgroundMessages({
requestOpptyProducts, requestOpptyProducts,
runtimeApi runtimeApi
}) { }) {
const bookmarkPending = new Map();
async function getBookmarkFolders() {
const folders = [];
function visit(nodes, ancestors = []) {
for (const node of nodes) {
if (node.url || node.unmodifiable) continue;
const path = node.parentId ? [...ancestors, node.title] : ancestors;
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
visit(node.children || [], path);
}
}
visit(await runtimeApi.bookmarks.getTree());
return { ok: true, folders };
}
function addShortcutBookmark(parentId) {
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
const pending = (async () => {
const { folders } = await getBookmarkFolders();
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
const existing = await runtimeApi.bookmarks.search({ url });
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
return { ok: true, existing: false };
})().finally(() => { bookmarkPending.delete(parentId); });
bookmarkPending.set(parentId, pending);
return pending;
}
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) { if (!message || !message.type) {
return false; return false;
} }
if (message.type === messageTypes.addShortcutBookmark) {
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getBookmarkFolders) {
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getShortcutBookmarkStatus) {
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
.catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) { if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart; const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message })); operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));

View File

@@ -79,6 +79,15 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const contentModal = createContentModal({ const contentModal = createContentModal({
getShortcutBookmarkStatus: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.getShortcutBookmarkStatus }),
openBookmarkPicker: () => opportunitiesLinks.chooseBookmarkFolder(
() => platformContentMessages.sendRuntimeMessage({ type: messageTypes.getBookmarkFolders }),
async parentId => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.addShortcutBookmark, parentId });
if (result?.ok) contentModal.hideBookmarkHint();
return result;
}
),
createManageTimeButton: () => manageTime.createButton(), createManageTimeButton: () => manageTime.createButton(),
prepareSrSession: () => srSessionPreparation.ensure(), prepareSrSession: () => srSessionPreparation.ensure(),
closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args), closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
@@ -212,7 +221,9 @@ function createContentApplication(overrides = {}) {
return result.items; return result.items;
} }
}); });
const dialogDrag = createDialogDrag({ environment });
const contentStyles = createContentStyles({ const contentStyles = createContentStyles({
enableDialogDrag: () => dialogDrag.install(),
STYLE_ID: contracts.STYLE_ID, STYLE_ID: contracts.STYLE_ID,
redwoodStyles, redwoodStyles,
environment environment
@@ -250,6 +261,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const opportunitiesController = createOpportunitiesController({ const opportunitiesController = createOpportunitiesController({
loadServiceRequests: (token, version) => opportunityCrmServiceRequests.load(token, version),
ensureCurrentUser: () => currentUser.ensure(), ensureCurrentUser: () => currentUser.ensure(),
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args), resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); }, resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
@@ -296,8 +308,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 +317,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
@@ -352,6 +364,9 @@ function createContentApplication(overrides = {}) {
requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar) requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar)
}); });
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({ const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
onStateChange: () => {
if (environment.document?.getElementById?.(contracts.OPPORTUNITIES_TABLE_ID)) opportunitiesTable.renderOpportunitiesTable();
},
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value), formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
createSrButton: number => crmSrForm.createButton(number), createSrButton: number => crmSrForm.createButton(number),
closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); }, closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); },
@@ -364,7 +379,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),
@@ -467,6 +518,7 @@ function createContentApplication(overrides = {}) {
}); });
const opportunitiesLinks = createOpportunitiesLinks({ const opportunitiesLinks = createOpportunitiesLinks({
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
searchableSelect,
environment environment
}); });
const preferencesStorage = createPreferencesStorage({ const preferencesStorage = createPreferencesStorage({

View File

@@ -0,0 +1,71 @@
function createDialogDrag({ environment }) {
const { document, window } = environment;
const panels = ".opportunities-extension-action-form-dialog, .opportunities-extension-preferences-dialog, .opportunities-extension-account-plans-dialog, .oe-org-confirm-panel";
const positions = new WeakMap();
let installed = false, drag = null;
function move(panel, dx, dy) {
const rect = panel.getBoundingClientRect();
const viewport = window.visualViewport;
const left = viewport?.offsetLeft || 0, top = viewport?.offsetTop || 0;
const width = viewport?.width || document.documentElement.clientWidth;
const height = viewport?.height || document.documentElement.clientHeight;
const x = Math.max(left - rect.width * 0.7, Math.min(rect.left + dx, left + width - rect.width * 0.3));
const y = Math.max(top - rect.height * 0.7, Math.min(rect.top + dy, top + height - rect.height * 0.3));
const previous = positions.get(panel) || { x: 0, y: 0 };
const next = { x: previous.x + x - rect.left, y: previous.y + y - rect.top };
positions.set(panel, next);
panel.style.position = "relative";
panel.style.left = `${next.x}px`;
panel.style.top = `${next.y}px`;
panel.setAttribute("data-dialog-moved", "true");
}
function stop() {
if (!drag) return;
const previous = drag;
drag = null;
previous.panel.removeAttribute("data-dialog-dragging");
if (previous.title.hasPointerCapture?.(previous.id)) previous.title.releasePointerCapture(previous.id);
}
function install() {
if (installed || window.__opportunitiesDialogDragInstalled || !document.addEventListener || !window.addEventListener) return;
installed = true;
window.__opportunitiesDialogDragInstalled = true;
document.addEventListener("pointerdown", event => {
if (event.button !== 0 || event.isPrimary === false) return;
const title = event.target.closest?.("header, h2, h3");
const panel = title?.closest(panels);
if (!panel || !panel.closest(".opportunities-extension-modal") || panel.closest("[inert], [hidden]") ||
event.target.closest("button, a, input, select, textarea, [role='button'], [contenteditable='true']")) return;
// Accept the whole title bar, but not headers inside the dialog's content.
const heading = panel.querySelector("h2, h3");
if (!heading || (heading !== title && !title.contains(heading))) return;
const rect = panel.getBoundingClientRect();
if (rect.width >= document.documentElement.clientWidth - 2 && rect.height >= document.documentElement.clientHeight - 2) return;
stop();
event.preventDefault();
drag = { title, panel, id: event.pointerId, x: event.clientX, y: event.clientY };
panel.setAttribute("data-dialog-dragging", "true");
title.setPointerCapture?.(event.pointerId);
});
document.addEventListener("pointermove", event => {
if (!drag || event.pointerId !== drag.id) return;
if (!drag.panel.isConnected || drag.panel.closest("[hidden], [inert]")) { stop(); return; }
event.preventDefault();
move(drag.panel, event.clientX - drag.x, event.clientY - drag.y);
drag.x = event.clientX; drag.y = event.clientY;
});
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) {
document.addEventListener(name, event => { if (drag?.id === event.pointerId) stop(); });
}
window.addEventListener("blur", stop);
function resize() {
stop();
for (const panel of document.querySelectorAll('[data-dialog-moved="true"]')) {
if (panel.getClientRects().length) move(panel, 0, 0);
}
}
window.addEventListener("resize", resize);
window.visualViewport?.addEventListener("resize", resize);
}
return { install };
}

View File

@@ -9,6 +9,20 @@ function createContentFusionPage({
environment environment
}) { }) {
const { Node, document, window } = environment; const { Node, document, window } = environment;
let shortcutOpened = false;
function openShortcutIfRequested() {
if (shortcutOpened || !isAllowedPage()) return;
if (window.top && window.top !== window) return;
let pending = false;
try {
const timestamp = Number(window.sessionStorage.getItem("opportunitiesExtension.shortcutIntent"));
pending = timestamp > 0 && Date.now() >= timestamp && Date.now() - timestamp < 10 * 60 * 1000;
window.sessionStorage.removeItem("opportunitiesExtension.shortcutIntent");
} catch { /* Storage may be disabled. */ }
if (!pending && window.location.hash !== "#opportunities-extension") return;
shortcutOpened = true;
openOpportunitiesModal();
}
function createOpportunitiesTile() { function createOpportunitiesTile() {
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "flat-grid-cell"; wrapper.className = "flat-grid-cell";
@@ -88,6 +102,9 @@ function createContentFusionPage({
} }
function insertTile() { function insertTile() {
// Opening a bookmarked shortcut must not depend on Sales being expanded
// or its tile insertion point being present in Fusion's lazy navigation.
openShortcutIfRequested();
if (document.getElementById(TILE_ID)) { if (document.getElementById(TILE_ID)) {
return true; return true;
} }

View File

@@ -3,6 +3,7 @@ function startContentApplication({ environment, page, styles, products }) {
if (!page.isAllowedPage()) return; if (!page.isAllowedPage()) return;
styles.ensureExtensionStyles(); styles.ensureExtensionStyles();
products.ensureProductsBackgroundMessageHandler(); products.ensureProductsBackgroundMessageHandler();
environment.window?.addEventListener?.("hashchange", () => page.insertTile());
if (page.insertTile()) return; if (page.insertTile()) return;
const observer = new environment.MutationObserver(() => page.insertTile()); const observer = new environment.MutationObserver(() => page.insertTile());
observer.observe(environment.document.documentElement, { childList: true, subtree: true }); observer.observe(environment.document.documentElement, { childList: true, subtree: true });

View File

@@ -1,4 +1,6 @@
function createContentModal({ function createContentModal({
openBookmarkPicker,
getShortcutBookmarkStatus,
ACCOUNT_PLANS_MODAL_ID, ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS, AUTH_STATUS,
CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_BUTTON_ID,
@@ -72,6 +74,27 @@ function createContentModal({
}) { }) {
const { document, window, setTimeout, clearTimeout, matchMedia } = environment; const { document, window, setTimeout, clearTimeout, matchMedia } = environment;
let authCompletion = null; let authCompletion = null;
let bookmarkHintTimer = null;
let bookmarkHintVersion = 0;
function hideBookmarkHint() {
bookmarkHintVersion++;
if (bookmarkHintTimer !== null) clearTimeout(bookmarkHintTimer);
bookmarkHintTimer = null;
const hint = document.getElementById("opportunities-extension-bookmark-hint");
if (hint) hint.hidden = true;
}
async function refreshBookmarkHint() {
hideBookmarkHint();
const version = bookmarkHintVersion;
try {
const result = await getShortcutBookmarkStatus?.();
const modal = document.getElementById(MODAL_ID);
const hint = document.getElementById("opportunities-extension-bookmark-hint");
if (version !== bookmarkHintVersion || !modal || modal.hidden || !hint || !result?.ok || result.bookmarked) return;
hint.hidden = false;
bookmarkHintTimer = setTimeout(hideBookmarkHint, 20000);
} catch { /* A failed bookmarks check must not interrupt opportunities. */ }
}
function resetAuthCompletion() { function resetAuthCompletion() {
if (!authCompletion) return; if (!authCompletion) return;
clearTimeout(authCompletion.waitTimer); clearTimeout(authCompletion.waitTimer);
@@ -89,6 +112,7 @@ function createContentModal({
if (existingModal) { if (existingModal) {
existingModal.hidden = false; existingModal.hidden = false;
refreshBookmarkHint();
prepareSrSession?.(); prepareSrSession?.();
existingModal.querySelector("select").focus(); existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock"); document.documentElement.classList.add("opportunities-extension-scroll-lock");
@@ -148,7 +172,13 @@ function createContentModal({
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp"; productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
productsCacheTimestamp.setAttribute("role", "status"); productsCacheTimestamp.setAttribute("role", "status");
productsCacheTimestamp.hidden = true; productsCacheTimestamp.hidden = true;
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp); const bookmarkHint = document.createElement("span");
bookmarkHint.id = "opportunities-extension-bookmark-hint";
bookmarkHint.className = "opportunities-extension-products-cache-timestamp";
bookmarkHint.setAttribute("role", "status");
bookmarkHint.textContent = "Save to favorites: Ctrl+Shift+F";
bookmarkHint.hidden = true;
subtitleRow.append(productsCacheTimestamp, bookmarkHint, authBadge, productsProgressBadge);
const titleBlock = document.createElement("div"); const titleBlock = document.createElement("div");
titleBlock.className = "opportunities-extension-title-row"; titleBlock.className = "opportunities-extension-title-row";
@@ -420,6 +450,7 @@ function createContentModal({
shell.append(header, body); shell.append(header, body);
overlay.append(shell); overlay.append(shell);
document.body.append(overlay); document.body.append(overlay);
refreshBookmarkHint();
document.documentElement.classList.add("opportunities-extension-scroll-lock"); document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler(); ensureEscapeKeyHandler();
renderStageFilterButtons(); renderStageFilterButtons();
@@ -472,6 +503,7 @@ function createContentModal({
function closeOpportunitiesModal() { function closeOpportunitiesModal() {
if (closeOrganizationChart()) return; if (closeOrganizationChart()) return;
hideBookmarkHint();
resetAuthCompletion(); resetAuthCompletion();
closeCrmSrPopover(); closeCrmSrPopover();
const modal = document.getElementById(MODAL_ID); const modal = document.getElementById(MODAL_ID);
@@ -502,10 +534,14 @@ function createContentModal({
return; return;
} }
if (event.ctrlKey && event.key.toLowerCase() === "d") { const shortcutKey = event.key.toLowerCase();
if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey &&
(shortcutKey === "d" || shortcutKey === "f")) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
toggleDebugPanel(); if (event.repeat) return;
if (shortcutKey === "d") toggleDebugPanel();
else openBookmarkPicker();
return; return;
} }
@@ -580,5 +616,5 @@ function createContentModal({
}); });
} }
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler }; return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler, hideBookmarkHint, refreshBookmarkHint };
} }

View File

@@ -0,0 +1,11 @@
// Capture the bookmark before Fusion rewrites its navigation URL.
(() => {
if (window.top !== window) return;
const capture = () => {
const url = new URL(window.location.href);
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
};
capture();
window.addEventListener("hashchange", capture);
})();

View File

@@ -1,10 +1,12 @@
function createContentStyles({ function createContentStyles({
STYLE_ID, STYLE_ID,
enableDialogDrag,
redwoodStyles, redwoodStyles,
environment environment
}) { }) {
const { document } = environment; const { document } = environment;
function ensureExtensionStyles() { function ensureExtensionStyles() {
enableDialogDrag?.();
if (document.getElementById(STYLE_ID)) { if (document.getElementById(STYLE_ID)) {
return; return;
} }

View File

@@ -1,5 +1,23 @@
/* Modal titles follow the customer Service Requests heading. Keep section and /* Modal titles follow the customer Service Requests heading. Keep section and
calendar headings independent from dialog typography. */ calendar headings independent from dialog typography. */
.opportunities-extension-modal :is(.opportunities-extension-action-form-dialog,
.opportunities-extension-preferences-dialog, .opportunities-extension-account-plans-dialog,
.oe-org-confirm-panel) :is(header, h2, h3) {
cursor: grab;
touch-action: none;
user-select: none;
}
.opportunities-extension-modal [data-dialog-dragging="true"] :is(header, h2, h3) { cursor: grabbing; }
.opportunities-extension-modal #opportunities-extension-bookmark-hint {
background: #9a4600;
color: #ffffff;
border: 1px solid #b95b00;
}
.opportunities-extension-modal[data-theme="dark"] #opportunities-extension-bookmark-hint {
background: #ffca70;
color: #3b2400;
border-color: #ffd99a;
}
.opportunities-extension-modal :is( .opportunities-extension-modal :is(
.opportunities-extension-header, .opportunities-extension-header,
.opportunities-extension-account-plans-header, .opportunities-extension-account-plans-header,

View File

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

View File

@@ -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";

View File

@@ -64,7 +64,10 @@ function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSum
function toggle(kind, value) { function toggle(kind, value) {
const selected = selections[kind]; const selected = selections[kind];
if (!selected || !allowsCrmActivities()) return; if (!selected || !allowsCrmActivities()) return;
if (selected.has(value)) selected.delete(value); if (selected.has("matched") && selected.has("missing")) {
selected.clear();
selected.add(value);
} else if (selected.has(value)) selected.delete(value);
else { else {
if (value === "under4") selected.clear(); if (value === "under4") selected.clear();
else selected.delete("under4"); else selected.delete("under4");

View File

@@ -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;
@@ -210,6 +210,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
viewbar.append(tablist); viewbar.append(tablist);
if (productTypeSelection) { if (productTypeSelection) {
const filters = node("div", "opportunities-product-type-filters"); const filters = node("div", "opportunities-product-type-filters");
const filterButtons = new Map();
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type"); filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
for (const type of ["Booking", "Workload"]) { for (const type of ["Booking", "Workload"]) {
const button = node("button", "opportunities-product-type-filter", type); const button = node("button", "opportunities-product-type-filter", type);
@@ -217,12 +218,19 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
button.setAttribute("data-type", type); button.setAttribute("data-type", type);
button.setAttribute("aria-pressed", String(productTypeSelection.has(type))); button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
button.addEventListener("click", () => { button.addEventListener("click", () => {
if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type); if (["Booking", "Workload"].every(value => productTypeSelection.has(value))) {
button.setAttribute("aria-pressed", String(productTypeSelection.has(type))); productTypeSelection.clear();
productTypeSelection.add(type);
} else if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
for (const [value, filterButton] of filterButtons) {
filterButton.setAttribute("aria-pressed", String(productTypeSelection.has(value)));
}
onProductTypeChange(); onProductTypeChange();
}); });
filterButtons.set(type, button);
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);

View File

@@ -13,6 +13,7 @@ function createOpportunitiesController({
productsContentState, productsContentState,
renderProductsProgress, renderProductsProgress,
requestOpportunitiesPage, requestOpportunitiesPage,
loadServiceRequests = async () => {},
resetCrmServiceRequests, resetCrmServiceRequests,
resetWorkloadActions, resetWorkloadActions,
resetWorkloads, resetWorkloads,
@@ -95,6 +96,9 @@ function createOpportunitiesController({
status: "ready", status: "ready",
message: "" message: ""
}); });
// Start SR only after all opportunity pages have been displayed.
// Its incremental updates run independently of the opportunity request.
try { Promise.resolve(loadServiceRequests(token, requestVersion)).catch(() => {}); } catch { /* Keep the displayed opportunities available. */ }
requestOpptyProductsInBackground(allItems, token); requestOpptyProductsInBackground(allItems, token);
} }
} catch (error) { } catch (error) {

View File

@@ -1,4 +1,4 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment }) { function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment, onStateChange = () => {} }) {
let status = "idle"; let status = "idle";
let byOpportunity = new Map(); let byOpportunity = new Map();
let pending = null; let pending = null;
@@ -20,6 +20,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
async function run(token, version, currentGeneration, cacheOptions) { async function run(token, version, currentGeneration, cacheOptions) {
const isActive = () => currentGeneration === generation; const isActive = () => currentGeneration === generation;
status = "loading"; status = "loading";
onStateChange();
try { try {
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null; const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
if (!isActive()) return false; if (!isActive()) return false;
@@ -27,12 +28,28 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
let items = cached?.items; let items = cached?.items;
if (!items) { if (!items) {
reportProgress("running", 0); reportProgress("running", 0);
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => { items = await requestCrmServiceRequest(token, version, isActive, (count, total, partialItems) => {
if (isActive()) reportProgress("running", count, total); if (!isActive()) return;
reportProgress("running", count, total);
if (Array.isArray(partialItems)) {
updateItems(partialItems);
onStateChange();
}
}); });
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items); if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
} }
if (!items || !isActive()) return false; if (!items || !isActive()) return false;
updateItems(items);
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
onStateChange();
return true;
} catch {
if (isActive()) { status = "error"; reportProgress("error"); onStateChange(); }
return false;
}
}
function updateItems(items) {
const grouped = new Map(); const grouped = new Map();
for (const item of items) { for (const item of items) {
const number = item?.OpportunityName_Id_c?.OptyNumber; const number = item?.OpportunityName_Id_c?.OptyNumber;
@@ -43,13 +60,6 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
} }
byOpportunity = grouped; byOpportunity = grouped;
loadedItems = items; loadedItems = items;
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
return true;
} catch {
if (isActive()) { status = "error"; reportProgress("error"); }
return false;
}
} }
function getSummary(optyNumber) { function getSummary(optyNumber) {
if (!allowsCrmActivities() || !optyNumber) return null; if (!allowsCrmActivities() || !optyNumber) return null;
@@ -58,22 +68,23 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value)) (typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
).map(Number); ).map(Number);
return { return {
state: status === "ready" ? items.length ? "matched" : "missing" : status, state: status === "loading" && items.length ? "matched" : status === "ready" ? items.length ? "matched" : "missing" : status,
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null, reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
hasUnderFourHours: status === "ready" && hours.some(value => value < 4) hasUnderFourHours: (status === "ready" || status === "loading") && hours.some(value => value < 4)
}; };
} }
function createBadge(optyNumber) { function createBadge(optyNumber) {
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null; const badgeStatus = status === "loading" && byOpportunity.get(String(optyNumber))?.length ? "ready" : status;
if (!allowsCrmActivities() || !optyNumber || badgeStatus === "idle" || badgeStatus === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || []; const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span"); const badge = environment.document.createElement(items.length && badgeStatus === "ready" ? "button" : "span");
badge.className = "opportunities-extension-crm-sr-badge"; badge.className = "opportunities-extension-crm-sr-badge";
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing"); badge.setAttribute("data-state", badgeStatus === "error" ? "error" : items.length ? "matched" : "missing");
const summary = getSummary(optyNumber); const summary = getSummary(optyNumber);
const reportedHours = summary.reportedHours !== null const reportedHours = summary.reportedHours !== null
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours) ? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
: "N/A"; : "N/A";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h"); setBadgeContent(badge, "sr", badgeStatus === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value); const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
const descriptions = items.map(item => [ const descriptions = items.map(item => [
`Title: ${display(item.Title)}`, `Title: ${display(item.Title)}`,
@@ -84,13 +95,13 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`, `Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}` `Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
].join("\n")); ].join("\n"));
const description = status === "error" ? "Unable to check CRM service requests. See Debug Requests." const description = badgeStatus === "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.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n"); : items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
badge.setAttribute("aria-label", description); badge.setAttribute("aria-label", description);
badge.title = "Whether a Service Request has been created for the opportunities."; badge.title = "Whether a Service Request has been created for the opportunities.";
if (items.length && status === "ready") bindPopover(badge, items, descriptions); if (items.length && badgeStatus === "ready") bindPopover(badge, items, descriptions);
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber))); if (!items.length && badgeStatus === "ready") badge.append(createSrButton(String(optyNumber)));
badge.tabIndex = 0; badge.tabIndex = 0;
return badge; return badge;
} }

View File

@@ -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));

View File

@@ -64,7 +64,10 @@ function createOpportunitiesFilters({
} }
function toggleStageFilter(stage) { function toggleStageFilter(stage) {
if (opportunitiesFilterState.selectedStages.has(stage)) { if (STAGE_OPTIONS.every(value => opportunitiesFilterState.selectedStages.has(value))) {
opportunitiesFilterState.selectedStages.clear();
opportunitiesFilterState.selectedStages.add(stage);
} else if (opportunitiesFilterState.selectedStages.has(stage)) {
opportunitiesFilterState.selectedStages.delete(stage); opportunitiesFilterState.selectedStages.delete(stage);
} else { } else {
opportunitiesFilterState.selectedStages.add(stage); opportunitiesFilterState.selectedStages.add(stage);
@@ -99,7 +102,10 @@ function createOpportunitiesFilters({
} }
function toggleStatusFilter(status) { function toggleStatusFilter(status) {
if (opportunitiesFilterState.selectedStatuses.has(status)) { if (STATUS_OPTIONS.every(value => opportunitiesFilterState.selectedStatuses.has(value))) {
opportunitiesFilterState.selectedStatuses.clear();
opportunitiesFilterState.selectedStatuses.add(status);
} else if (opportunitiesFilterState.selectedStatuses.has(status)) {
opportunitiesFilterState.selectedStatuses.delete(status); opportunitiesFilterState.selectedStatuses.delete(status);
} else { } else {
opportunitiesFilterState.selectedStatuses.add(status); opportunitiesFilterState.selectedStatuses.add(status);
@@ -246,7 +252,10 @@ function createOpportunitiesFilters({
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key); checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
checkbox.addEventListener("change", () => { checkbox.addEventListener("change", () => {
if (checkbox.checked) { if (getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key))) {
opportunitiesFilterState.selectedCustomerKeys.clear();
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
} else if (checkbox.checked) {
opportunitiesFilterState.selectedCustomerKeys.add(customer.key); opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
} else { } else {
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key); opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
@@ -316,7 +325,10 @@ function createOpportunitiesFilters({
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key); checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
checkbox.addEventListener("change", () => { checkbox.addEventListener("change", () => {
if (checkbox.checked) { if (getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key))) {
opportunitiesFilterState.selectedOwnerKeys.clear();
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
} else if (checkbox.checked) {
opportunitiesFilterState.selectedOwnerKeys.add(owner.key); opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
} else { } else {
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key); opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);

View File

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

View File

@@ -1,8 +1,140 @@
function createOpportunitiesLinks({ function createOpportunitiesLinks({
MODAL_ID, MODAL_ID,
searchableSelect,
environment environment
}) { }) {
const { document, navigator, window } = environment; const { document, navigator, window } = environment;
function chooseBookmarkFolder(loadFolders, saveBookmark) {
const modal = document.getElementById(MODAL_ID);
if (!modal || modal.querySelector(".oe-bookmark-picker")) return;
const previous = document.activeElement;
const overlay = document.createElement("div");
overlay.className = "opportunities-extension-action-form-modal oe-bookmark-picker";
const panel = document.createElement("section");
panel.className = "opportunities-extension-action-form-dialog";
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", "oe-bookmark-title");
const header = document.createElement("header");
header.className = "opportunities-extension-account-plans-header";
const title = document.createElement("h2");
title.id = "oe-bookmark-title";
title.textContent = "Add to favorites";
const dismiss = document.createElement("button");
dismiss.type = "button";
dismiss.className = "opportunities-extension-icon-button";
dismiss.setAttribute("aria-label", "Close favorites form");
const closeIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
closeIcon.setAttribute("viewBox", "0 0 24 24");
closeIcon.setAttribute("aria-hidden", "true");
const closePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
closePath.setAttribute("d", "M6 6l12 12M18 6 6 18");
closePath.setAttribute("stroke", "currentColor");
closePath.setAttribute("stroke-width", "1.75");
closeIcon.append(closePath);
dismiss.append(closeIcon);
header.append(title, dismiss);
const body = document.createElement("div");
body.className = "opportunities-extension-action-form";
const label = document.createElement("label");
label.className = "opportunities-extension-action-form-field opportunities-extension-action-form-wide";
label.textContent = "Folder (required)";
const select = document.createElement("select");
select.id = "oe-bookmark-folder";
select.required = true;
select.setAttribute("aria-describedby", "oe-bookmark-status");
select.disabled = true;
label.append(select);
const status = document.createElement("p");
status.id = "oe-bookmark-status";
status.className = "opportunities-extension-action-form-status opportunities-extension-action-form-wide";
status.setAttribute("role", "status");
status.textContent = "Loading bookmarks folders…";
const actions = document.createElement("footer");
actions.className = "opportunities-extension-action-form-footer";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.textContent = "Cancel";
const save = document.createElement("button");
save.type = "button";
save.className = "opportunities-extension-action-form-save";
save.textContent = "Save";
save.disabled = true;
const autocomplete = searchableSelect(select, label, () => { save.disabled = !select.value; }, { floating: true });
autocomplete.input.setAttribute("aria-describedby", "oe-bookmark-status");
let saving = false;
const siblings = Array.from(modal.children).map(node => [node, node.inert]);
siblings.forEach(([node]) => { node.inert = true; });
function close() {
if (saving) return;
autocomplete.destroy();
overlay.remove();
siblings.forEach(([node, inert]) => { node.inert = inert; });
previous?.focus();
}
cancel.addEventListener("click", close);
dismiss.addEventListener("click", close);
save.addEventListener("click", async () => {
if (!select.value || saving) return;
saving = true;
save.disabled = cancel.disabled = select.disabled = dismiss.disabled = true;
autocomplete.sync();
status.setAttribute("role", "status");
status.textContent = "Saving favorite…";
try {
const result = await saveBookmark(select.value);
if (!result?.ok) throw new Error(result?.error || "Unable to add favorite.");
saving = false;
close();
showCopyConfirmation(result.existing ? "This shortcut is already in the selected folder." : "Opportunities Extension added to favorites.");
} catch (error) {
status.setAttribute("role", "alert");
status.textContent = error.message;
saving = false;
save.disabled = cancel.disabled = select.disabled = dismiss.disabled = false;
autocomplete.sync();
}
});
overlay.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const controls = [dismiss, autocomplete.input, cancel, save].filter(control => !control.disabled);
event.preventDefault();
const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length]?.focus();
}
});
actions.append(cancel, save);
body.append(label, status, actions);
panel.append(header, body);
overlay.append(panel);
modal.append(overlay);
cancel.focus();
Promise.resolve().then(loadFolders).then(result => {
if (!overlay.isConnected) return;
if (!result?.ok) throw new Error(result?.error || "Unable to load bookmarks folders.");
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select a folder";
select.append(placeholder);
for (const folder of result.folders) {
const option = document.createElement("option");
option.value = folder.id;
option.textContent = folder.label;
select.append(option);
}
select.disabled = !result.folders.length;
status.textContent = result.folders.length ? "Choose where to save Opportunities Extension." : "No writable bookmarks folders available.";
select.addEventListener("change", () => { save.disabled = !select.value; });
autocomplete.sync();
if (!select.disabled) autocomplete.input.focus();
}).catch(error => {
if (overlay.isConnected) {
status.setAttribute("role", "alert");
status.textContent = error.message;
}
});
}
function createDetailLink(label, href) { function createDetailLink(label, href) {
if (label === null || label === undefined || label === "") { if (label === null || label === undefined || label === "") {
const placeholder = document.createElement("span"); const placeholder = document.createElement("span");
@@ -114,5 +246,5 @@ function createOpportunitiesLinks({
return badge; return badge;
} }
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge }; return { chooseBookmarkFolder, createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
} }

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

View File

@@ -111,10 +111,14 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.checked = selectedOrders.has(value); checkbox.checked = selectedOrders.has(value);
checkbox.addEventListener("change", () => { checkbox.addEventListener("change", () => {
if (checkbox.checked) selectedOrders.add(value); else selectedOrders.delete(value); if (orders.every(order => selectedOrders.has(order))) {
selectedOrders.clear();
selectedOrders.add(value);
} else if (checkbox.checked) selectedOrders.add(value); else selectedOrders.delete(value);
allOrders.checked = selectedOrders.size === orders.length; allOrders.checked = selectedOrders.size === orders.length;
allOrders.indeterminate = selectedOrders.size > 0 && !allOrders.checked; allOrders.indeterminate = selectedOrders.size > 0 && !allOrders.checked;
orderButton.textContent = allOrders.checked ? "All order numbers" : `${selectedOrders.size} order numbers selected`; orderButton.textContent = allOrders.checked ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
updateOrders();
renderRows(); renderRows();
}); });
label.append(checkbox, element("span", null, value || "Not provided")); label.append(checkbox, element("span", null, value || "Not provided"));

View File

@@ -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"
@@ -48,7 +67,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
throw new Error("CRM service request pagination did not advance."); throw new Error("CRM service request pagination did not advance.");
} }
items.push(...data.items); items.push(...data.items);
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null); onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null, items.slice());
if (!data.hasMore) return items; if (!data.hasMore) return items;
offset = current + received; offset = current + received;
attempt = 1; attempt = 1;
@@ -60,5 +79,5 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
} }
return null; return null;
} }
return { createPayload, requestCrmServiceRequest }; return { createPayload, requestCrmServiceRequest, requestSRSearchResources };
} }

View File

@@ -37,7 +37,10 @@ function createFusionOpportunities({
const payload = createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber); const payload = createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber);
const requestBody = JSON.stringify(payload); const requestBody = JSON.stringify(payload);
// One history per query, preserving all pages of the same result set. // One history per query, preserving all pages of the same result set.
if (page === 1) resetDebugRequests(); if (page === 1) {
resetDebugRequests();
if (!isCurrentPeriodRequest(requestVersion)) return null;
}
const requestEntry = logRequestStart({ const requestEntry = logRequestStart({
name: "requestOpportunities", name: "requestOpportunities",
method: "POST", method: "POST",

View File

@@ -1,6 +1,9 @@
/** Stable wire names. All payloads remain plain, serializable objects. */ /** Stable wire names. All payloads remain plain, serializable objects. */
function createMessageTypes() { function createMessageTypes() {
return Object.freeze({ return Object.freeze({
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart", loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart", saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
requestWeekDetails: "opportunitiesExtension.requestWeekDetails", requestWeekDetails: "opportunitiesExtension.requestWeekDetails",

View File

@@ -61,11 +61,13 @@ test("activity badge filters precede Status, are accessible and combine without
h.sr.set("B", { state: "missing" }); h.sr.set("B", { state: "missing" });
h.actions.set("B", { state: "matched" }); h.actions.set("B", { state: "matched" });
assert.equal(h.matches("unknown"), true); assert.equal(h.matches("unknown"), true);
h.click("sr", "missing"); h.click("sr", "matched");
assert.equal(h.button("sr", "matched").attributes["aria-pressed"], "true");
assert.equal(h.button("sr", "missing").attributes["aria-pressed"], "false");
assert.equal(h.matches("A"), true); assert.equal(h.matches("A"), true);
assert.equal(h.matches("B"), false); assert.equal(h.matches("B"), false);
assert.equal(h.matches("unknown"), false); assert.equal(h.matches("unknown"), false);
h.click("actions", "missing"); h.click("actions", "matched");
assert.equal(h.matches("A"), false, "SR and Action use AND"); assert.equal(h.matches("A"), false, "SR and Action use AND");
h.actions.set("A", { state: "matched" }); h.actions.set("A", { state: "matched" });
assert.equal(h.matches("A"), true); assert.equal(h.matches("A"), true);
@@ -119,8 +121,8 @@ test("filter vectors retain their fixed slot without duplication when selection
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => { test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
const h = setup(); const h = setup();
h.click("sr", "matched"); h.click("sr", "missing");
h.click("actions", "matched"); h.click("actions", "missing");
for (const state of ["idle", "loading", "error", "matched"]) { for (const state of ["idle", "loading", "error", "matched"]) {
h.sr.set("A", { state }); h.sr.set("A", { state });
h.actions.set("A", { state: "missing" }); h.actions.set("A", { state: "missing" });
@@ -215,7 +217,7 @@ test("activity filters compose with existing search, status, owner and customer
h.app.opportunitiesFilters.resetOwnerFilter(items); h.app.opportunitiesFilters.resetOwnerFilter(items);
h.sr.set("A", { state: "matched" }); h.sr.set("A", { state: "matched" });
h.sr.set("B", { state: "missing" }); h.sr.set("B", { state: "missing" });
h.click("sr", "missing"); h.click("sr", "matched");
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber); const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
assert.deepEqual(results(), ["A"]); assert.deepEqual(results(), ["A"]);
h.app.opportunitiesFilterState.tableSearch = "Test B"; h.app.opportunitiesFilterState.tableSearch = "Test B";

View File

@@ -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 = () => {};

View File

@@ -14,7 +14,7 @@ const json = value => JSON.parse(JSON.stringify(value));
test("every source belongs to an explicit runtime composition, with no duplicate factories", () => { test("every source belongs to an explicit runtime composition, with no duplicate factories", () => {
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true }) const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
.filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort(); .filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort();
assert.deepEqual([...new Set([...Object.values(sources).flat(), ...Object.values(vendorSources).flat()])].sort(), actual); assert.deepEqual([...new Set(["src/content/shortcut-intent.js", ...Object.values(sources).flat(), ...Object.values(vendorSources).flat()])].sort(), actual);
for (const files of Object.values(vendorSources)) { for (const files of Object.values(vendorSources)) {
for (const file of files) { for (const file of files) {
assert.doesNotThrow(() => new vm.Script(read(file))); assert.doesNotThrow(() => new vm.Script(read(file)));

View File

@@ -0,0 +1,47 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const factory = vm.runInNewContext(fs.readFileSync('src/content/modal.js', 'utf8') + '\ncreateContentModal');
function setup(check) {
const hint = { hidden: true }, modal = { hidden: false }, timers = new Map();
let next = 0;
const app = factory({ MODAL_ID: 'modal', getShortcutBookmarkStatus: check,
environment: { window: {}, document: { getElementById: id => id === 'modal' ? modal : hint },
setTimeout(fn, delay) { const id = ++next; timers.set(id, { fn, delay }); return id; },
clearTimeout(id) { timers.delete(id); }
}
});
return { app, hint, modal, timers };
}
test('unbookmarked shortcut shows for 20 seconds and can show on reopening', async () => {
const h = setup(async () => ({ ok: true, bookmarked: false }));
await h.app.refreshBookmarkHint();
assert.equal(h.hint.hidden, false);
const timer = [...h.timers.values()][0];
assert.equal(timer.delay, 20000);
timer.fn();
assert.equal(h.hint.hidden, true);
await h.app.refreshBookmarkHint();
assert.equal(h.hint.hidden, false);
h.app.hideBookmarkHint();
assert.equal(h.timers.size, 0);
});
test('existing bookmark and failed checks never display the hint', async () => {
for (const check of [async () => ({ ok: true, bookmarked: true }), async () => ({ ok: false }), async () => { throw Error('unavailable'); }]) {
const h = setup(check);
await h.app.refreshBookmarkHint();
assert.equal(h.hint.hidden, true);
assert.equal(h.timers.size, 0);
}
});
test('closing or saving while the check is pending prevents late hints', async () => {
let resolve;
const h = setup(() => new Promise(done => { resolve = done; }));
const pending = h.app.refreshBookmarkHint();
h.app.hideBookmarkHint();
resolve({ ok: true, bookmarked: false });
await pending;
assert.equal(h.hint.hidden, true);
assert.equal(h.timers.size, 0);
});

View File

@@ -0,0 +1,64 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
function harness(bookmarks) {
let listener;
const context = vm.createContext({});
vm.runInContext(fs.readFileSync('src/background/messages.js', 'utf8'), context);
context.registerBackgroundMessages({
messageTypes: { addShortcutBookmark: 'bookmark', getBookmarkFolders: 'folders' },
runtimeApi: { bookmarks: { getTree: async () => [{ id: "0", children: [{ id: "1", parentId: "0", title: "Favorites" }] }], ...bookmarks }, runtime: { onMessage: { addListener(fn) { listener = fn; } } } }
});
return (message = { type: "bookmark", parentId: "1" }) => new Promise(resolve => {
assert.equal(listener(message, {}, resolve), true);
});
}
test('creates only the fixed extension shortcut, deduplicating concurrent requests', async () => {
const created = [];
const request = harness({ search: async () => [], create: async value => { created.push(value); } });
const results = await Promise.all([request(), request()]);
assert.equal(created.length, 1);
assert.equal(created[0].parentId, '1');
assert.equal(created[0].title, 'Opportunities Extension');
assert.equal(created[0].url, 'https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension');
assert.ok(results.every(result => result.ok));
});
test('does not duplicate an existing bookmark', async () => {
const request = harness({ search: async () => [{ id: '2', parentId: '1' }], create: async () => assert.fail('duplicate') });
assert.equal((await request()).existing, true);
});
test('reports API errors and allows retry', async () => {
let calls = 0;
const request = harness({ search: async () => {
if (++calls === 1) throw new Error('Permission denied');
return [{ id: '2', parentId: '1' }];
} });
assert.equal((await request()).error, 'Permission denied');
assert.equal((await request()).ok, true);
});
test('lists nested writable folders without bookmarks or managed folders', async () => {
const request = harness({ getTree: async () => [{ id: '0', children: [
{ id: '1', parentId: '0', title: 'Favorites', children: [
{ id: '2', parentId: '1', title: 'Work', children: [] },
{ id: '3', parentId: '1', title: 'Link', url: 'https://example.com' }
] },
{ id: '4', parentId: '0', title: 'Managed', unmodifiable: 'managed' }
] }] });
const result = await request({ type: 'folders' });
assert.deepEqual(JSON.parse(JSON.stringify(result.folders)), [
{ id: '1', label: 'Favorites' }, { id: '2', label: 'Favorites / Work' }
]);
});
test('rejects missing or unavailable destinations without creating a bookmark', async () => {
const request = harness({ create: async () => assert.fail('invalid destination') });
assert.equal((await request({ type: 'bookmark' })).ok, false);
assert.equal((await request({ type: 'bookmark', parentId: 'unknown' })).ok, false);
});

View File

@@ -40,9 +40,12 @@ test("distribution contracts: manifests, execution boundaries and deterministic
const base = { const base = {
manifest_version: 3, name: "Opportunities Extension", manifest_version: 3, name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.8", permissions: ["cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"], version: "1.1.0", permissions: ["bookmarks", "cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"], host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"],
content_scripts: [{ content_scripts: [{
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
js: ["shortcut-intent.js"], all_frames: false, run_at: "document_start"
}, {
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"], matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
js: ["content.js"], all_frames: true, run_at: "document_idle" js: ["content.js"], all_frames: true, run_at: "document_idle"
}, { }, {

View File

@@ -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;

View File

@@ -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 = [];

View File

@@ -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() {

View File

@@ -194,7 +194,7 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
assert.equal(same, app.currentUser.ensure()); assert.equal(same, app.currentUser.ensure());
finish({ ok: true, user: { ...validIdentity.user, roles } }); finish({ ok: true, user: { ...validIdentity.user, roles } });
await first; await first;
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]); assert.deepEqual(calls, [type, "tokenrelay", "opportunities", ...(allowed ? ["crm"] : [])]);
app.productsContentState.currentProductsRequestId = "products-cycle"; app.productsContentState.currentProductsRequestId = "products-cycle";
app.productActivities.begin("products-cycle", "synthetic"); app.productActivities.begin("products-cycle", "synthetic");
const products = { items: [{ RevnId: "123" }] }; const products = { items: [{ RevnId: "123" }] };

76
tests/dialogDrag.test.cjs Normal file
View File

@@ -0,0 +1,76 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const factory = vm.runInNewContext(fs.readFileSync('src/content/dialog-drag.js', 'utf8') + '\ncreateDialogDrag');
function setup({ header = false, interactive = false, unrelated = false } = {}) {
const events = {}, winEvents = {}, attrs = {}, style = {};
const panel = { style, isConnected: true, full: false,
closest: selector => selector.includes('modal') ? {} : null,
querySelector: () => title,
setAttribute: (key, value) => attrs[key] = value,
removeAttribute: key => delete attrs[key],
getClientRects: () => [1],
getBoundingClientRect() {
const left = 200 + (parseFloat(style.left) || 0), top = 150 + (parseFloat(style.top) || 0);
return { left, top, width: this.full ? 1000 : 400, height: this.full ? 800 : 300 };
}
};
const title = { closest: () => panel, setPointerCapture() {}, hasPointerCapture: () => true, releasePointerCapture() {} };
const bar = { ...title, contains: node => !unrelated && node === title };
const target = { closest: selector => selector === 'header, h2, h3' ? (header ? bar : title) : interactive ? {} : null };
const document = { documentElement: { clientWidth: 1000, clientHeight: 800 },
addEventListener: (name, fn) => events[name] = fn, querySelectorAll: () => [panel] };
const window = { addEventListener: (name, fn) => winEvents[name] = fn };
factory({ environment: { document, window } }).install();
const event = (x, y) => ({ target, button: 0, pointerId: 1, clientX: x, clientY: y, preventDefault() {} });
return { panel, events, winEvents, attrs, style, document, event };
}
test('dragging permits 70 percent overflow in every direction and stops on release', () => {
const h = setup();
h.events.pointerdown(h.event(220, 160));
h.events.pointermove(h.event(2000, 2000));
assert.equal(h.panel.getBoundingClientRect().left, 880);
assert.equal(h.panel.getBoundingClientRect().top, 710);
h.events.pointermove(h.event(-2000, -2000));
assert.equal(h.panel.getBoundingClientRect().left, -280);
assert.equal(h.panel.getBoundingClientRect().top, -210);
h.events.pointerup(h.event(0, 0));
assert.equal(h.attrs['data-dialog-dragging'], undefined);
h.events.pointermove(h.event(400, 400));
assert.equal(h.panel.getBoundingClientRect().left, -280);
});
test('full-page panels do not drag', () => {
const h = setup(); h.panel.full = true;
h.events.pointerdown(h.event(220, 160));
h.events.pointermove(h.event(300, 200));
assert.equal(h.style.left, undefined);
});
test('resize keeps at least 30 percent of a moved panel inside each viewport axis', () => {
const h = setup();
h.events.pointerdown(h.event(220, 160));
h.events.pointermove(h.event(500, 400));
h.document.documentElement.clientWidth = 600;
h.document.documentElement.clientHeight = 500;
h.winEvents.resize();
assert.equal(h.panel.getBoundingClientRect().left, 480);
assert.equal(h.panel.getBoundingClientRect().top, 390);
assert.equal(h.attrs['data-dialog-dragging'], undefined);
});
test('empty title bar space starts dragging', () => {
const h = setup({ header: true });
h.events.pointerdown(h.event(220, 160));
h.events.pointermove(h.event(300, 200));
assert.equal(h.panel.getBoundingClientRect().left, 280);
assert.equal(h.panel.getBoundingClientRect().top, 190);
});
test('title bar controls and content headers do not start dragging', () => {
for (const options of [{ header: true, interactive: true }, { header: true, unrelated: true }]) {
const h = setup(options);
h.events.pointerdown(h.event(220, 160));
h.events.pointermove(h.event(300, 200));
assert.equal(h.style.left, undefined);
}
});

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

View File

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

View File

@@ -0,0 +1,41 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
test('modal shortcuts require exactly Ctrl+Shift+D or Ctrl+Shift+F', () => {
let listener, debug = 0, favorites = 0;
const modal = { hidden: false };
const context = vm.createContext({});
vm.runInContext(fs.readFileSync('src/content/modal.js', 'utf8'), context);
const app = context.createContentModal({
MODAL_ID: 'modal', ESCAPE_LISTENER_KEY: 'keys',
toggleDebugPanel: () => debug++, openBookmarkPicker: () => favorites++,
environment: { window: {}, document: {
getElementById: () => modal,
addEventListener: (name, fn) => { if (name === 'keydown') listener = fn; }
} }
});
app.ensureEscapeKeyHandler();
function press(key, extra = {}) {
let prevented = false;
listener({ key, ctrlKey: true, shiftKey: true, preventDefault() { prevented = true; }, stopPropagation() {}, ...extra });
return prevented;
}
assert.equal(press('D'), true);
assert.equal(press('f'), true);
assert.equal(debug, 1);
assert.equal(favorites, 1);
for (const key of ['d', 'f']) {
assert.equal(press(key, { shiftKey: false }), false);
assert.equal(press(key, { ctrlKey: false }), false);
assert.equal(press(key, { altKey: true }), false);
assert.equal(press(key, { metaKey: true }), false);
press(key, { repeat: true });
}
modal.hidden = true;
assert.equal(press('d'), false);
assert.equal(press('f'), false);
assert.equal(debug, 1);
assert.equal(favorites, 1);
});

View File

@@ -9,7 +9,7 @@ const plain = value => JSON.parse(JSON.stringify(value));
const opportunity = (number, date) => ({ OptyNumber: number, Name: `<Opportunity ${number}>`, EffectiveDate: date, const opportunity = (number, date) => ({ OptyNumber: number, Name: `<Opportunity ${number}>`, EffectiveDate: date,
ForecastGroup_c: "FORECAST", StatusCode: "OPEN", CustomerAccount: { PartyUniqueName: "Customer" } }); ForecastGroup_c: "FORECAST", StatusCode: "OPEN", CustomerAccount: { PartyUniqueName: "Customer" } });
function setup(items = [opportunity("A", "2026-11-15")]) { function setup(items = [opportunity("A", "2026-11-15")], extra = {}) {
let document; let document;
class Element { class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; } constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
@@ -44,7 +44,7 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"), createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"),
createViewIcon: view => { const icon = new Element("svg"); icon.view = view; return icon; }, createViewIcon: view => { const icon = new Element("svg"); icon.view = view; return icon; },
createStageBadge: () => new Element("span"), createStatusBadge: () => new Element("span"), createStageBadge: () => new Element("span"), createStatusBadge: () => new Element("span"),
opportunityUrl: "https://example.invalid/?puid=", modalId: "modal", closeSrPopover() {} }); opportunityUrl: "https://example.invalid/?puid=", modalId: "modal", closeSrPopover() {}, ...extra });
h.body = new Element("body"); h.table = new Element("table-surface"); host.append(h.body); h.body = new Element("body"); h.table = new Element("table-surface"); host.append(h.body);
const walk = element => [element, ...element.children.flatMap(walk)]; const walk = element => [element, ...element.children.flatMap(walk)];
h.nodes = () => walk(host); h.nodes = () => walk(host);
@@ -54,6 +54,23 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
return h; return h;
} }
test("Booking and Workload update both badges when isolating and adding selections", () => {
const selected = new Set(["Booking", "Workload"]);
let renders = 0;
const h = setup([], { productTypeSelection: selected, onProductTypeChange: () => renders++ });
h.mount();
const booking = h.nodes().find(node => node.attributes["data-type"] === "Booking");
const workload = h.nodes().find(node => node.attributes["data-type"] === "Workload");
for (const [button, expected] of [[booking, [true,false]], [workload, [true,true]], [workload, [false,true]], [booking, [true,true]]]) {
button.click();
assert.equal(booking.attributes["aria-pressed"], String(expected[0]));
assert.equal(workload.attributes["aria-pressed"], String(expected[1]));
assert.equal(selected.has("Booking"), expected[0]);
assert.equal(selected.has("Workload"), expected[1]);
}
assert.equal(renders, 4);
});
test("calendar controls, accessibility labels and missing dates are English", () => { test("calendar controls, accessibility labels and missing dates are English", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", null)]); const h = setup([opportunity("A", "2026-11-15"), opportunity("B", null)]);
const { options } = h.activate(); const { options } = h.activate();

View File

@@ -0,0 +1,29 @@
const {test}=require('node:test');const assert=require('node:assert/strict');const fs=require('node:fs');const vm=require('node:vm');
const factory=vm.runInNewContext(fs.readFileSync('src/content/fusion-page.js','utf8')+'\ncreateContentFusionPage');
for(const hash of ['#opportunities-extension','','#other'])test(`shortcut opens once only with explicit marker ${hash}`,()=>{
let opened=0;const location={hostname:'eeho.fa.us2.oraclecloud.com',pathname:'/hcmUI/faces/FuseWelcome',hash};
const api=factory({TILE_ID:'tile',ALLOWED_PATHS:['/hcmUI/faces/FuseWelcome'],environment:{document:{getElementById:()=>({})},window:{location}},openOpportunitiesModal:()=>opened++});
api.insertTile();api.insertTile();assert.equal(opened,hash==='#opportunities-extension'?1:0);
});
test('shortcut never opens on another origin',()=>{
const api=factory({TILE_ID:'tile',ALLOWED_PATHS:['/hcmUI/faces/FuseWelcome'],environment:{document:{getElementById:()=>({})},window:{location:{hostname:'example.invalid',pathname:'/hcmUI/faces/FuseWelcome',hash:'#opportunities-extension'}}},openOpportunitiesModal:()=>assert.fail()});api.insertTile();
});
test('startup opens shortcut before Sales or the insertion point exists, without clicks',()=>{
let opened=0;let observing=0;
const document={documentElement:{},getElementById:()=>null,querySelector:()=>null};
const window={location:{hostname:'eeho.fa.us2.oraclecloud.com',pathname:'/hcmUI/faces/FuseWelcome',hash:'#opportunities-extension'}};
const environment={document,window,MutationObserver:class{constructor(fn){this.fn=fn;}observe(){observing++;this.fn();}}};
const page=factory({TILE_ID:'tile',ALLOWED_PATHS:['/hcmUI/faces/FuseWelcome'],environment,openOpportunitiesModal:()=>opened++});
const start=vm.runInNewContext(fs.readFileSync('src/content/main.js','utf8')+'\nstartContentApplication');
start({environment,page,styles:{ensureExtensionStyles(){}},products:{ensureProductsBackgroundMessageHandler(){}}});
assert.equal(opened,1);assert.equal(observing,1);assert.equal(page.insertTile(),false);assert.equal(opened,1);
});
test('early capture survives Fusion querystring replacement and is consumed once',()=>{
const values=new Map();const storage={setItem:(k,v)=>values.set(k,v),getItem:k=>values.get(k),removeItem:k=>values.delete(k)};
const window={location:{href:'https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension'},sessionStorage:storage,addEventListener(){}};window.top=window;
vm.runInNewContext(fs.readFileSync('src/content/shortcut-intent.js','utf8'),{window,URL,Date});
window.location={hostname:'eeho.fa.us2.oraclecloud.com',pathname:'/hcmUI/faces/FuseWelcome',hash:'',search:'?_afrLoop=123'};
let opened=0;const options={TILE_ID:'tile',ALLOWED_PATHS:['/hcmUI/faces/FuseWelcome'],environment:{document:{getElementById:()=>({})},window},openOpportunitiesModal:()=>opened++};
factory(options).insertTile();factory(options).insertTile();assert.equal(opened,1);assert.equal(values.size,0);
});

View File

@@ -0,0 +1,11 @@
const {test}=require('node:test');const assert=require('node:assert/strict');const fs=require('node:fs');const vm=require('node:vm');
const factory=vm.runInNewContext(fs.readFileSync('src/features/opportunities/controller.js','utf8')+'\ncreateOpportunitiesController');
for(const mode of ['success','failure','stale','pending'])test(`opportunities are displayed before background SR: ${mode}`,async()=>{
const order=[];const state={periodRequestVersion:1};let complete;
const api=factory({opportunitiesQueryState:state,productsContentState:{},consumptionPlansState:{},consumptionTrendState:{},renderProductsProgress(){},setOpportunitiesTableState:next=>order.push(next.status),requestOpportunitiesPage:()=>{order.push('query');return new Promise(resolve=>complete=resolve);},loadServiceRequests:()=>{order.push('sr');if(mode==='failure')throw Error('SR failed');if(mode==='pending')return new Promise(()=>{});},requestOpptyProductsInBackground:()=>order.push('products')});
const request=api.requestOpportunities('token','CQ',{},1);
assert.deepEqual(order,['loading','query']);
if(mode==='stale')state.periodRequestVersion++;
complete([{OptyNumber:'A'}]);await request;
assert.deepEqual(order,mode==='stale'?['loading','query']:['loading','query','ready','sr','products']);
});