4 Commits

43 changed files with 1686 additions and 197 deletions

View File

@@ -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,
@@ -393,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.

View File

@@ -1,5 +1,15 @@
# Release Notes # 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 ## Release 1.0.9 - 2026-10-28
- Added a landscape print/PDF view with filters, totals, and opportunities. - Added a landscape print/PDF view with filters, totals, and opportunities.
@@ -8,81 +18,6 @@
- Align opportunity badges and customer actions consistently. - Align opportunity badges and customer actions consistently.
- Move release history from README to this dedicated file. - Move release history from README to this dedicated file.
## 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 ## 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 customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
@@ -95,3 +30,78 @@
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support. - 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. - 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. - 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.9", "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.9", "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.9", "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.9", 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

@@ -32,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(); },
@@ -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); },
@@ -503,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

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

@@ -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,10 +218,16 @@ 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()); if (createPrintButton) filters.append(createPrintButton());

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

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

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

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

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

@@ -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.9", 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

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