Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2363e37aab
|
|||
|
9cdb46b6c2
|
|||
|
00bcfdc42e
|
|||
|
9b5e751179
|
|||
|
88de9b81b7
|
|||
|
3fa212e561
|
98
README.md
98
README.md
@@ -1,4 +1,4 @@
|
||||
# Opportunities Extension 1.0.7
|
||||
# Opportunities Extension
|
||||
|
||||
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar.
|
||||
|
||||
@@ -316,15 +316,15 @@ scrolling is reserved for table containers below
|
||||
1120px. The scoped table stylesheet does not change product-table column widths
|
||||
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
|
||||
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
|
||||
mask authorization and sensitive response headers before messaging the source
|
||||
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
|
||||
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+D
|
||||
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+Shift+D
|
||||
returns to the underlying view. Header/category navigation remains above the
|
||||
scrollable timeline; expanded details use two columns on wide screens and one on
|
||||
narrow screens. Theme-specific colors distinguish success, failure, running,
|
||||
@@ -372,94 +372,6 @@ AI text and consumption; unexpected requests fail closed. It does not authentica
|
||||
against Fusion or read real extension storage. `/consumption-trend.html` provides
|
||||
a focused chart fixture. Stop the server with Ctrl+C when finished.
|
||||
|
||||
## Release 1.0.0
|
||||
|
||||
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
|
||||
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
|
||||
- Opportunity type view preferences persisted in the browser.
|
||||
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
|
||||
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
|
||||
- Light and dark themes based on Oracle Redwood.
|
||||
- Separate outputs for Chromium/Edge and Firefox.
|
||||
|
||||
## Release 1.0.1
|
||||
|
||||
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
|
||||
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
|
||||
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
|
||||
|
||||
## Release 1.0.2
|
||||
|
||||
- Modularized the extension with regression tests and documented execution boundaries.
|
||||
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
|
||||
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
|
||||
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
|
||||
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
|
||||
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
|
||||
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
|
||||
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
|
||||
|
||||
## Release 1.0.3 - 2026-10-13
|
||||
|
||||
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
|
||||
- Added searchable Action forms, required-field validation, saving and local badge updates.
|
||||
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
|
||||
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
|
||||
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
|
||||
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
|
||||
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
|
||||
|
||||
## Release 1.0.4 - 2026-10-19
|
||||
|
||||
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
|
||||
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
|
||||
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
|
||||
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
|
||||
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
|
||||
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
|
||||
|
||||
## Release 1.0.5 - 2026-10-20
|
||||
|
||||
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
|
||||
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
|
||||
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
|
||||
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
|
||||
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
|
||||
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
|
||||
|
||||
## Release 1.0.6 - 2026-10-21
|
||||
|
||||
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
|
||||
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
|
||||
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
|
||||
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
|
||||
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
|
||||
|
||||
## Release 1.0.7 - 2026-10-26
|
||||
|
||||
- Remember table sorting preferences and restore the selected column and direction when reopening supported tables.
|
||||
- Retrieve the user's Opportunity saved searches before loading opportunities and use the default savedSearchNumber for copiedFrom across all result pages.
|
||||
- Show requestOpportunitiesSavedSearch in the Opportunities debug category and stop dependent queries when the default search is missing or invalid.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests were not performed for this release.
|
||||
|
||||
## Release 1.0.8 - 2026-10-27
|
||||
|
||||
- Added customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
|
||||
- Added paginated, sortable opportunities by contact using the contact PartyNumber.
|
||||
- Unified modal and table layouts, loading skeletons and typography; added Rate Card order-number filtering and simplified Overview customer names using PartyName.
|
||||
- Added Reference-bounded scrolling calendars with all daily opportunity cards and positioning around the current week.
|
||||
- Added Booking/Workload filters and improved ramp/projection access, alerts and unavailable-workload indicators.
|
||||
- Removed AI-generated opportunity summaries and their dedicated requests.
|
||||
- Persisted debug requests in localStorage with lazy rendering; history resets only when a new opportunity query starts.
|
||||
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support.
|
||||
- Prepare SPA and SR sessions concurrently, inspect SPA before visual assets finish loading, and provide SR session retry with a 70-second timeout.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests are not included.
|
||||
|
||||
## Loading in Development Mode
|
||||
|
||||
### Chrome or Microsoft Edge
|
||||
@@ -481,3 +393,5 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
|
||||
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
|
||||
|
||||
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.
|
||||
|
||||
With the Opportunities Extension modal open, press Ctrl+Shift+F to choose a browser bookmarks folder and save the extension shortcut.
|
||||
|
||||
107
RELEASE_NOTES.md
Normal file
107
RELEASE_NOTES.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Release Notes
|
||||
|
||||
## Release 1.1.0 - 2026-10-30
|
||||
|
||||
- Display opportunities first, then load Service Requests and update their badges in the background.
|
||||
- Clicking an item when all filter choices are selected now selects that item alone, including Booking and Workload.
|
||||
- Added a bookmark shortcut that opens the extension automatically from Fusion.
|
||||
- Ctrl+Shift+F opens a searchable bookmarks folder picker; Ctrl+Shift+D toggles Debug Requests.
|
||||
- Show a prominent favorites shortcut hint for 20 seconds when the shortcut is not bookmarked; keep Last update first in the header.
|
||||
- Smaller dialogs can be dragged by their title bars, with up to 70% overflow beyond viewport edges.
|
||||
- Updated Chromium/Edge and Firefox builds. Automated validation uses synthetic data; live Oracle and installed-browser testing are not included.
|
||||
|
||||
## Release 1.0.9 - 2026-10-28
|
||||
|
||||
- Added a landscape print/PDF view with filters, totals, and opportunities.
|
||||
- Resolve the CRM Service Request saved search dynamically before querying records.
|
||||
- Restore hidden-iframe Internal SR session recovery with bounded retries and shared pending requests.
|
||||
- Align opportunity badges and customer actions consistently.
|
||||
- Move release history from README to this dedicated file.
|
||||
|
||||
## Release 1.0.8 - 2026-10-27
|
||||
|
||||
- Added customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
|
||||
- Added paginated, sortable opportunities by contact using the contact PartyNumber.
|
||||
- Unified modal and table layouts, loading skeletons and typography; added Rate Card order-number filtering and simplified Overview customer names using PartyName.
|
||||
- Added Reference-bounded scrolling calendars with all daily opportunity cards and positioning around the current week.
|
||||
- Added Booking/Workload filters and improved ramp/projection access, alerts and unavailable-workload indicators.
|
||||
- Removed AI-generated opportunity summaries and their dedicated requests.
|
||||
- Persisted debug requests in localStorage with lazy rendering; history resets only when a new opportunity query starts.
|
||||
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support.
|
||||
- Prepare SPA and SR sessions concurrently, inspect SPA before visual assets finish loading, and provide SR session retry with a 70-second timeout.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests are not included.
|
||||
|
||||
## Release 1.0.7 - 2026-10-26
|
||||
|
||||
- Remember table sorting preferences and restore the selected column and direction when reopening supported tables.
|
||||
- Retrieve the user's Opportunity saved searches before loading opportunities and use the default savedSearchNumber for copiedFrom across all result pages.
|
||||
- Show requestOpportunitiesSavedSearch in the Opportunities debug category and stop dependent queries when the default search is missing or invalid.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests were not performed for this release.
|
||||
|
||||
## Release 1.0.6 - 2026-10-21
|
||||
|
||||
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
|
||||
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
|
||||
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
|
||||
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
|
||||
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
|
||||
|
||||
## Release 1.0.5 - 2026-10-20
|
||||
|
||||
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
|
||||
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
|
||||
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
|
||||
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
|
||||
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
|
||||
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
|
||||
|
||||
## Release 1.0.4 - 2026-10-19
|
||||
|
||||
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
|
||||
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
|
||||
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
|
||||
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
|
||||
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
|
||||
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
|
||||
|
||||
## Release 1.0.3 - 2026-10-13
|
||||
|
||||
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
|
||||
- Added searchable Action forms, required-field validation, saving and local badge updates.
|
||||
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
|
||||
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
|
||||
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
|
||||
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
|
||||
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
|
||||
|
||||
## Release 1.0.2
|
||||
|
||||
- Modularized the extension with regression tests and documented execution boundaries.
|
||||
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
|
||||
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
|
||||
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
|
||||
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
|
||||
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
|
||||
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
|
||||
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
|
||||
|
||||
## Release 1.0.1
|
||||
|
||||
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
|
||||
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
|
||||
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
|
||||
|
||||
## Release 1.0.0
|
||||
|
||||
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
|
||||
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
|
||||
- Opportunity type view preferences persisted in the browser.
|
||||
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
|
||||
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
|
||||
- Light and dark themes based on Oracle Redwood.
|
||||
- Separate outputs for Chromium/Edge and Firefox.
|
||||
45
dist/chromium/background.js
vendored
45
dist/chromium/background.js
vendored
@@ -292,6 +292,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
|
||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||
function createMessageTypes() {
|
||||
return Object.freeze({
|
||||
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
|
||||
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
|
||||
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
|
||||
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
||||
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||
@@ -3137,10 +3140,52 @@ function registerBackgroundMessages({
|
||||
requestOpptyProducts,
|
||||
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) => {
|
||||
if (!message || !message.type) {
|
||||
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)) {
|
||||
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
||||
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||
|
||||
659
dist/chromium/content.js
vendored
659
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
14
dist/chromium/manifest.json
vendored
14
dist/chromium/manifest.json
vendored
@@ -2,8 +2,9 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Opportunities Extension",
|
||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
"version": "1.0.8",
|
||||
"version": "1.1.0",
|
||||
"permissions": [
|
||||
"bookmarks",
|
||||
"cookies",
|
||||
"storage",
|
||||
"webRequest",
|
||||
@@ -15,6 +16,17 @@
|
||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||
],
|
||||
"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*",
|
||||
|
||||
11
dist/chromium/shortcut-intent.js
vendored
Normal file
11
dist/chromium/shortcut-intent.js
vendored
Normal 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);
|
||||
})();
|
||||
45
dist/firefox/background.js
vendored
45
dist/firefox/background.js
vendored
@@ -292,6 +292,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
|
||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||
function createMessageTypes() {
|
||||
return Object.freeze({
|
||||
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
|
||||
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
|
||||
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
|
||||
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
||||
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||
@@ -3137,10 +3140,52 @@ function registerBackgroundMessages({
|
||||
requestOpptyProducts,
|
||||
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) => {
|
||||
if (!message || !message.type) {
|
||||
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)) {
|
||||
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
||||
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||
|
||||
659
dist/firefox/content.js
vendored
659
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
14
dist/firefox/manifest.json
vendored
14
dist/firefox/manifest.json
vendored
@@ -2,8 +2,9 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Opportunities Extension",
|
||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
"version": "1.0.8",
|
||||
"version": "1.1.0",
|
||||
"permissions": [
|
||||
"bookmarks",
|
||||
"cookies",
|
||||
"storage",
|
||||
"webRequest",
|
||||
@@ -15,6 +16,17 @@
|
||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||
],
|
||||
"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*",
|
||||
|
||||
11
dist/firefox/shortcut-intent.js
vendored
Normal file
11
dist/firefox/shortcut-intent.js
vendored
Normal 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);
|
||||
})();
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opportunities-extension",
|
||||
"version": "1.0.8",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,14 +9,16 @@ const distDir = path.join(rootDir, "dist");
|
||||
const contentScript = composition.composeScript("content");
|
||||
const backgroundScript = composition.composeScript("background");
|
||||
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 baseManifest = {
|
||||
manifest_version: 3,
|
||||
name: "Opportunities Extension",
|
||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
version: "1.0.8",
|
||||
version: "1.1.0",
|
||||
permissions: [
|
||||
"bookmarks",
|
||||
"cookies",
|
||||
"storage",
|
||||
"webRequest",
|
||||
@@ -28,6 +30,12 @@ const baseManifest = {
|
||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||
],
|
||||
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*",
|
||||
@@ -77,7 +85,7 @@ for (const target of targets) {
|
||||
JSON.parse(manifestJson);
|
||||
|
||||
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)) {
|
||||
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.`);
|
||||
@@ -90,6 +98,7 @@ for (const target of targets) {
|
||||
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
||||
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, "shortcut-intent.js"), shortcutScript);
|
||||
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ module.exports = {
|
||||
"src/features/opportunities/dashboard-state.js",
|
||||
"src/features/debug/state.js",
|
||||
"src/features/opportunities/columns.js",
|
||||
"src/features/opportunities/print.js",
|
||||
"src/features/opportunities/badge-content.js",
|
||||
"src/content/fusion-page.js",
|
||||
"src/content/modal.js",
|
||||
@@ -31,6 +32,7 @@ module.exports = {
|
||||
"src/content/icons.js",
|
||||
"src/content/svg.js",
|
||||
"src/content/styles.js",
|
||||
"src/content/dialog-drag.js",
|
||||
"src/auth/content-session.js",
|
||||
"src/auth/current-user.js",
|
||||
"src/auth/token-relay.js",
|
||||
|
||||
@@ -44,10 +44,52 @@ function registerBackgroundMessages({
|
||||
requestOpptyProducts,
|
||||
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) => {
|
||||
if (!message || !message.type) {
|
||||
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)) {
|
||||
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
||||
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||
|
||||
@@ -79,6 +79,15 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
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(),
|
||||
prepareSrSession: () => srSessionPreparation.ensure(),
|
||||
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;
|
||||
}
|
||||
});
|
||||
const dialogDrag = createDialogDrag({ environment });
|
||||
const contentStyles = createContentStyles({
|
||||
enableDialogDrag: () => dialogDrag.install(),
|
||||
STYLE_ID: contracts.STYLE_ID,
|
||||
redwoodStyles,
|
||||
environment
|
||||
@@ -250,6 +261,7 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
const opportunitiesController = createOpportunitiesController({
|
||||
loadServiceRequests: (token, version) => opportunityCrmServiceRequests.load(token, version),
|
||||
ensureCurrentUser: () => currentUser.ensure(),
|
||||
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
||||
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
|
||||
@@ -296,8 +308,8 @@ function createContentApplication(overrides = {}) {
|
||||
});
|
||||
const internalSrSession = createInternalSrSession({
|
||||
environment,
|
||||
query: async srId => {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
|
||||
query: async (srId, srNumber) => {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId, srNumber });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
return result;
|
||||
}
|
||||
@@ -305,7 +317,7 @@ function createContentApplication(overrides = {}) {
|
||||
const crmSrPopover = createCrmSrPopover({
|
||||
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
requestDetails: srId => internalSrSession.request(srId),
|
||||
requestDetails: (srId, srNumber) => internalSrSession.request(srId, srNumber),
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
||||
@@ -352,6 +364,9 @@ function createContentApplication(overrides = {}) {
|
||||
requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar)
|
||||
});
|
||||
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
|
||||
onStateChange: () => {
|
||||
if (environment.document?.getElementById?.(contracts.OPPORTUNITIES_TABLE_ID)) opportunitiesTable.renderOpportunitiesTable();
|
||||
},
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
createSrButton: number => crmSrForm.createButton(number),
|
||||
closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); },
|
||||
@@ -364,7 +379,43 @@ function createContentApplication(overrides = {}) {
|
||||
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
||||
});
|
||||
const opportunitiesPrint = createOpportunitiesPrint({ environment, getSnapshot: () => {
|
||||
const state = opportunitiesTableState.opportunitiesTableState;
|
||||
const items = opportunitiesFilters.getSortedOpportunities();
|
||||
const filter = opportunitiesFilterState;
|
||||
const selectedLabels = (options, selected, allLabel) => {
|
||||
const matching = options.filter(option => selected.has(option.key));
|
||||
if (options.length && matching.length === options.length) return allLabel;
|
||||
return matching.map(option => option.label).join(", ") || "None";
|
||||
};
|
||||
const root = environment.document.getElementById(contracts.MODAL_ID);
|
||||
const filters = [
|
||||
["Reference", root?.querySelector(".opportunities-extension-form select")?.value || "-"],
|
||||
["Customer", selectedLabels(opportunitiesFormatters.getCustomerOptions(), filter.selectedCustomerKeys, "All customers")],
|
||||
["Owner", selectedLabels(opportunitiesFormatters.getOwnerOptions(), filter.selectedOwnerKeys, "All owners")],
|
||||
["Search", filter.tableSearch || "All"],
|
||||
["Status", [...filter.selectedStatuses].join(", ")],
|
||||
["Stage", [...filter.selectedStages].join(", ")],
|
||||
["Opportunity type", [...filter.selectedProductTypes].join(", ")]
|
||||
];
|
||||
for (const group of root?.querySelectorAll(".opportunities-extension-activity-filter-controls") || []) {
|
||||
filters.push([group.getAttribute("aria-label").replace(/^Filter by /, ""),
|
||||
[...group.querySelectorAll('[aria-pressed="true"]')].map(button => button.getAttribute("aria-label")).join("; ")]);
|
||||
}
|
||||
const totals = [{ label: "Total Opportunities", value: `${items.length}/${state.items.length}`, detail: "Filtered / total" }];
|
||||
for (const stage of contracts.STAGE_OPTIONS) {
|
||||
const stageItems = items.filter(item => item.ForecastGroup_c === stage);
|
||||
const amount = stageItems.reduce((total, item) => {
|
||||
const value = Number(item.PrimaryRevenue?.RevnAmount);
|
||||
return total + (Number.isFinite(value) ? value : 0);
|
||||
}, 0);
|
||||
totals.push({ label: stage, value: opportunitiesDashboard.createStageAmountFormatter(stageItems)(amount), detail: `${stageItems.length} opportunities` });
|
||||
}
|
||||
return { status: state.status, filters, totals, columns,
|
||||
rows: items.map(item => columns.map(column => opportunitiesFormatters.displayOpportunityValue(column, item))) };
|
||||
} });
|
||||
const opportunityCalendar = createOpportunityCalendar({
|
||||
createPrintButton: () => opportunitiesPrint.createButton(),
|
||||
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
|
||||
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
|
||||
createViewIcon: view => contentIcons.createViewIcon(view),
|
||||
@@ -467,6 +518,7 @@ function createContentApplication(overrides = {}) {
|
||||
});
|
||||
const opportunitiesLinks = createOpportunitiesLinks({
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
searchableSelect,
|
||||
environment
|
||||
});
|
||||
const preferencesStorage = createPreferencesStorage({
|
||||
|
||||
71
src/content/dialog-drag.js
Normal file
71
src/content/dialog-drag.js
Normal 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 };
|
||||
}
|
||||
@@ -9,6 +9,20 @@ function createContentFusionPage({
|
||||
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() {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "flat-grid-cell";
|
||||
@@ -88,6 +102,9 @@ function createContentFusionPage({
|
||||
}
|
||||
|
||||
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)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ function startContentApplication({ environment, page, styles, products }) {
|
||||
if (!page.isAllowedPage()) return;
|
||||
styles.ensureExtensionStyles();
|
||||
products.ensureProductsBackgroundMessageHandler();
|
||||
environment.window?.addEventListener?.("hashchange", () => page.insertTile());
|
||||
if (page.insertTile()) return;
|
||||
const observer = new environment.MutationObserver(() => page.insertTile());
|
||||
observer.observe(environment.document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
function createContentModal({
|
||||
openBookmarkPicker,
|
||||
getShortcutBookmarkStatus,
|
||||
ACCOUNT_PLANS_MODAL_ID,
|
||||
AUTH_STATUS,
|
||||
CUSTOMER_FILTER_BUTTON_ID,
|
||||
@@ -72,6 +74,27 @@ function createContentModal({
|
||||
}) {
|
||||
const { document, window, setTimeout, clearTimeout, matchMedia } = environment;
|
||||
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() {
|
||||
if (!authCompletion) return;
|
||||
clearTimeout(authCompletion.waitTimer);
|
||||
@@ -89,6 +112,7 @@ function createContentModal({
|
||||
|
||||
if (existingModal) {
|
||||
existingModal.hidden = false;
|
||||
refreshBookmarkHint();
|
||||
prepareSrSession?.();
|
||||
existingModal.querySelector("select").focus();
|
||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||
@@ -148,7 +172,13 @@ function createContentModal({
|
||||
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
||||
productsCacheTimestamp.setAttribute("role", "status");
|
||||
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");
|
||||
titleBlock.className = "opportunities-extension-title-row";
|
||||
@@ -420,6 +450,7 @@ function createContentModal({
|
||||
shell.append(header, body);
|
||||
overlay.append(shell);
|
||||
document.body.append(overlay);
|
||||
refreshBookmarkHint();
|
||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||
ensureEscapeKeyHandler();
|
||||
renderStageFilterButtons();
|
||||
@@ -472,6 +503,7 @@ function createContentModal({
|
||||
|
||||
function closeOpportunitiesModal() {
|
||||
if (closeOrganizationChart()) return;
|
||||
hideBookmarkHint();
|
||||
resetAuthCompletion();
|
||||
closeCrmSrPopover();
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
@@ -502,10 +534,14 @@ function createContentModal({
|
||||
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.stopPropagation();
|
||||
toggleDebugPanel();
|
||||
if (event.repeat) return;
|
||||
if (shortcutKey === "d") toggleDebugPanel();
|
||||
else openBookmarkPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -580,5 +616,5 @@ function createContentModal({
|
||||
});
|
||||
}
|
||||
|
||||
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler };
|
||||
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler, hideBookmarkHint, refreshBookmarkHint };
|
||||
}
|
||||
|
||||
11
src/content/shortcut-intent.js
Normal file
11
src/content/shortcut-intent.js
Normal 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);
|
||||
})();
|
||||
@@ -1,10 +1,12 @@
|
||||
function createContentStyles({
|
||||
STYLE_ID,
|
||||
enableDialogDrag,
|
||||
redwoodStyles,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function ensureExtensionStyles() {
|
||||
enableDialogDrag?.();
|
||||
if (document.getElementById(STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
/* Modal titles follow the customer Service Requests heading. Keep section and
|
||||
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-header,
|
||||
.opportunities-extension-account-plans-header,
|
||||
|
||||
@@ -78,7 +78,18 @@
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
|
||||
/* Preserve room for Overview badges and the compact daily entry controls. */
|
||||
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 38px; vertical-align: top; }
|
||||
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 10px; vertical-align: top; }
|
||||
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content > .opportunities-extension-cell-actions {
|
||||
position: static;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; }
|
||||
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }
|
||||
|
||||
@@ -27,7 +27,7 @@ function createDebugContentView({
|
||||
if (entry.name === "requestOpptyProducts") return "products";
|
||||
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
||||
if (entry.name === "requestInstallBase") return "installBase";
|
||||
if (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||
if (["requestSRSearchResources", "requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
|
||||
return "other";
|
||||
|
||||
@@ -64,7 +64,10 @@ function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSum
|
||||
function toggle(kind, value) {
|
||||
const selected = selections[kind];
|
||||
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 {
|
||||
if (value === "under4") selected.clear();
|
||||
else selected.delete("under4");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
|
||||
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
|
||||
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
|
||||
productTypeSelection, onProductTypeChange }) {
|
||||
productTypeSelection, onProductTypeChange, createPrintButton }) {
|
||||
const { document } = environment;
|
||||
let elements = null;
|
||||
let calendar = null;
|
||||
@@ -210,6 +210,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
viewbar.append(tablist);
|
||||
if (productTypeSelection) {
|
||||
const filters = node("div", "opportunities-product-type-filters");
|
||||
const filterButtons = new Map();
|
||||
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
|
||||
for (const type of ["Booking", "Workload"]) {
|
||||
const button = node("button", "opportunities-product-type-filter", type);
|
||||
@@ -217,12 +218,19 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
button.setAttribute("data-type", type);
|
||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||
button.addEventListener("click", () => {
|
||||
if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
|
||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||
if (["Booking", "Workload"].every(value => productTypeSelection.has(value))) {
|
||||
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();
|
||||
});
|
||||
filterButtons.set(type, button);
|
||||
filters.append(button);
|
||||
}
|
||||
if (createPrintButton) filters.append(createPrintButton());
|
||||
viewbar.append(filters);
|
||||
}
|
||||
workspace.append(viewbar, tableSurface, surface); body.append(workspace);
|
||||
|
||||
@@ -13,6 +13,7 @@ function createOpportunitiesController({
|
||||
productsContentState,
|
||||
renderProductsProgress,
|
||||
requestOpportunitiesPage,
|
||||
loadServiceRequests = async () => {},
|
||||
resetCrmServiceRequests,
|
||||
resetWorkloadActions,
|
||||
resetWorkloads,
|
||||
@@ -95,6 +96,9 @@ function createOpportunitiesController({
|
||||
status: "ready",
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 byOpportunity = new Map();
|
||||
let pending = null;
|
||||
@@ -20,6 +20,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
async function run(token, version, currentGeneration, cacheOptions) {
|
||||
const isActive = () => currentGeneration === generation;
|
||||
status = "loading";
|
||||
onStateChange();
|
||||
try {
|
||||
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
|
||||
if (!isActive()) return false;
|
||||
@@ -27,12 +28,28 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
let items = cached?.items;
|
||||
if (!items) {
|
||||
reportProgress("running", 0);
|
||||
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => {
|
||||
if (isActive()) reportProgress("running", count, total);
|
||||
items = await requestCrmServiceRequest(token, version, isActive, (count, total, partialItems) => {
|
||||
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()) 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();
|
||||
for (const item of items) {
|
||||
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
||||
@@ -43,13 +60,6 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
}
|
||||
byOpportunity = grouped;
|
||||
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) {
|
||||
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))
|
||||
).map(Number);
|
||||
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,
|
||||
hasUnderFourHours: status === "ready" && hours.some(value => value < 4)
|
||||
hasUnderFourHours: (status === "ready" || status === "loading") && hours.some(value => value < 4)
|
||||
};
|
||||
}
|
||||
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 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.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 reportedHours = summary.reportedHours !== null
|
||||
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
|
||||
: "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 descriptions = items.map(item => [
|
||||
`Title: ${display(item.Title)}`,
|
||||
@@ -84,13 +95,13 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
|
||||
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
||||
].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.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
||||
badge.setAttribute("aria-label", description);
|
||||
badge.title = "Whether a Service Request has been created for the opportunities.";
|
||||
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
|
||||
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
|
||||
if (items.length && badgeStatus === "ready") bindPopover(badge, items, descriptions);
|
||||
if (!items.length && badgeStatus === "ready") badge.append(createSrButton(String(optyNumber)));
|
||||
badge.tabIndex = 0;
|
||||
return badge;
|
||||
}
|
||||
|
||||
@@ -124,9 +124,13 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
||||
children.setAttribute("role", "status");
|
||||
children.textContent = "Loading SR details...";
|
||||
section.append(children);
|
||||
detailLoads.push(async () => {
|
||||
const loadDetails = async () => {
|
||||
if (active !== entry || entry.panel !== panel) return;
|
||||
children.textContent = "Loading SR details...";
|
||||
children.setAttribute("aria-busy", "true");
|
||||
children.setAttribute("role", "status");
|
||||
try {
|
||||
const records = await requestDetails(item.SrId);
|
||||
const records = await requestDetails(item.SrId, item.SrNumber);
|
||||
if (active !== entry || entry.panel !== panel) return;
|
||||
children.textContent = "";
|
||||
if (!records.length) children.textContent = "No further details.";
|
||||
@@ -157,13 +161,20 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
||||
if (active !== entry || entry.panel !== panel) return;
|
||||
children.setAttribute("role", "alert");
|
||||
children.textContent = error.message || "Unable to load SR details.";
|
||||
const retry = document.createElement("button");
|
||||
retry.type = "button";
|
||||
retry.textContent = "Retry";
|
||||
retry.setAttribute("aria-label", "Retry loading Internal Service Requests");
|
||||
retry.addEventListener("click", () => { retry.disabled = true; loadDetails(); });
|
||||
children.append(retry);
|
||||
} finally {
|
||||
if (active === entry && entry.panel === panel) {
|
||||
children.setAttribute("aria-busy", "false");
|
||||
entry.position();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
detailLoads.push(loadDetails);
|
||||
panel.append(section);
|
||||
});
|
||||
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
||||
|
||||
@@ -64,7 +64,10 @@ function createOpportunitiesFilters({
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedStages.add(stage);
|
||||
@@ -99,7 +102,10 @@ function createOpportunitiesFilters({
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedStatuses.add(status);
|
||||
@@ -246,7 +252,10 @@ function createOpportunitiesFilters({
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
|
||||
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);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
|
||||
@@ -316,7 +325,10 @@ function createOpportunitiesFilters({
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
|
||||
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);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
|
||||
const { document, setTimeout } = environment;
|
||||
const pending = new Map();
|
||||
let recovery = null;
|
||||
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
|
||||
const now = () => environment.performance?.now() ?? Date.now();
|
||||
function isUnauthorized(result) {
|
||||
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
|
||||
if (status != null) return Number(status) === 401;
|
||||
// Older background workers may return only the request error text.
|
||||
return /failed with status 401\.$/.test(result?.error || "");
|
||||
return status != null ? Number(status) === 401 : /failed with status 401\.$/.test(result?.error || "");
|
||||
}
|
||||
async function warmSession(id) {
|
||||
async function warmSession(id, srNumber) {
|
||||
const document = environment?.document;
|
||||
if (!document?.body) throw new Error(`${sessionLabel} session cannot be initialized on this page.`);
|
||||
const frame = document.createElement("iframe");
|
||||
// Restore the original session initialization route without opening a tab.
|
||||
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
|
||||
frame.title = `${sessionLabel} session initialization`;
|
||||
frame.tabIndex = -1;
|
||||
@@ -18,43 +17,46 @@ function createInternalSrSession({ query, environment, sessionUrl, sessionLabel
|
||||
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||
frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;";
|
||||
let blocked = false;
|
||||
let loaded = false;
|
||||
frame.addEventListener("error", () => { blocked = true; });
|
||||
frame.addEventListener("load", () => { loaded = true; });
|
||||
const failed = () => { blocked = true; };
|
||||
frame.addEventListener("error", failed);
|
||||
try {
|
||||
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
|
||||
document.body.append(frame);
|
||||
const deadline = now() + 60000;
|
||||
for (let attempt = 0; now() < deadline; attempt += 1) {
|
||||
// Check immediately, then briefly poll faster before backing off.
|
||||
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now()));
|
||||
if (now() >= deadline) break;
|
||||
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`);
|
||||
const result = await query(id);
|
||||
if (result?.ok) return { id, result };
|
||||
// Keep the iframe alive while its application completes authentication.
|
||||
// A load event is not proof of a ready session. Limit and space API checks.
|
||||
for (const delay of [2000, 4000, 8000, 12000, 16000]) {
|
||||
await new Promise(resolve => environment.setTimeout(resolve, delay));
|
||||
if (blocked) throw new Error(`${sessionLabel} session iframe could not be loaded. Retry after signing in.`);
|
||||
const result = await query(id, srNumber);
|
||||
if (result?.ok) return { id: String(id), result };
|
||||
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||
}
|
||||
throw new Error(`${sessionLabel} session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`);
|
||||
throw new Error(`${sessionLabel} session is still unavailable. Retry loading the details after signing in.`);
|
||||
} finally {
|
||||
// Stop remaining page resources without stopping the caller's page.
|
||||
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ }
|
||||
frame.src = "about:blank";
|
||||
frame.removeEventListener("error", failed);
|
||||
try { frame.contentWindow?.stop(); } catch { /* SSO may redirect cross-origin. */ }
|
||||
frame.remove();
|
||||
}
|
||||
}
|
||||
async function request(id) {
|
||||
let result = await query(id);
|
||||
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) {
|
||||
if (!recovery) {
|
||||
recovery = warmSession(id);
|
||||
const pending = recovery;
|
||||
pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {});
|
||||
}
|
||||
const recovered = await recovery;
|
||||
result = recovered.id === id ? recovered.result : await query(id);
|
||||
function request(id, srNumber) {
|
||||
const key = String(id);
|
||||
if (!pending.has(key)) {
|
||||
const operation = (async () => {
|
||||
let result = await query(id, srNumber);
|
||||
if (!result?.ok && isUnauthorized(result)) {
|
||||
if (!recovery) {
|
||||
const current = warmSession(id, srNumber);
|
||||
recovery = current;
|
||||
current.finally(() => { if (recovery === current) recovery = null; }).catch(() => {});
|
||||
}
|
||||
const recovered = await recovery;
|
||||
result = recovered.id === key ? recovered.result : await query(id, srNumber);
|
||||
}
|
||||
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||
return result.items;
|
||||
})().finally(() => pending.delete(key));
|
||||
pending.set(key, operation);
|
||||
}
|
||||
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||
return result.items;
|
||||
return pending.get(key);
|
||||
}
|
||||
return { request };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,140 @@
|
||||
function createOpportunitiesLinks({
|
||||
MODAL_ID,
|
||||
searchableSelect,
|
||||
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) {
|
||||
if (label === null || label === undefined || label === "") {
|
||||
const placeholder = document.createElement("span");
|
||||
@@ -114,5 +246,5 @@ function createOpportunitiesLinks({
|
||||
return badge;
|
||||
}
|
||||
|
||||
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
|
||||
return { chooseBookmarkFolder, createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
|
||||
}
|
||||
|
||||
100
src/features/opportunities/print.js
Normal file
100
src/features/opportunities/print.js
Normal file
@@ -0,0 +1,100 @@
|
||||
function createOpportunitiesPrint({ environment, getSnapshot }) {
|
||||
function createButton() {
|
||||
const doc = environment.document;
|
||||
const button = doc.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-icon-button";
|
||||
button.title = "Print opportunities";
|
||||
button.setAttribute("aria-label", "Print opportunities");
|
||||
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "1.6");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
const path = doc.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", "M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6zM17 11h1");
|
||||
svg.append(path); button.append(svg);
|
||||
button.addEventListener("click", print);
|
||||
return button;
|
||||
}
|
||||
function print() {
|
||||
const snapshot = getSnapshot();
|
||||
if (!["ready", "empty"].includes(snapshot.status)) {
|
||||
environment.window.alert("Wait for opportunities to finish loading before printing.");
|
||||
return;
|
||||
}
|
||||
const popup = environment.window.open("", "_blank", "width=1200,height=850");
|
||||
if (!popup) { environment.window.alert("Allow pop-ups to print opportunities."); return; }
|
||||
popup.opener = null;
|
||||
const doc = popup.document;
|
||||
doc.title = "Opportunities Extension — Report";
|
||||
doc.documentElement.lang = "en";
|
||||
const node = (tag, text, parent) => {
|
||||
const el = doc.createElement(tag);
|
||||
if (text != null) el.textContent = String(text);
|
||||
if (parent) parent.append(el);
|
||||
return el;
|
||||
};
|
||||
node("style", `
|
||||
@page { size: A4 landscape; margin: 15mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 32px; font: 11px/1.4 Arial, sans-serif; color: #222; background: white; }
|
||||
h1 { margin: 0 0 4px; font-size: 21px; } h2 { font-size: 13px; margin: 16px 0 6px; }
|
||||
.meta { color: #555; margin: 0 0 12px; }
|
||||
.filters { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px 16px; border: 1px solid #ccc; padding: 10px; }
|
||||
.filters div { overflow-wrap: anywhere; } .filters strong { margin-right: 5px; }
|
||||
.totals { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 12px 0; }
|
||||
.totals section { border: 1px solid #bbb; border-top: 3px solid #426a75; padding: 8px; break-inside: avoid; }
|
||||
.totals strong { display: block; font-size: 16px; } .totals small { display: block; }
|
||||
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 9px; }
|
||||
th, td { padding: 6px 4px; border-bottom: 1px solid #ccc; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
||||
th { background: #eee; font-weight: bold; } tbody tr:nth-child(even) { background: #f7f7f7; }
|
||||
thead { display: table-header-group; } tr { break-inside: avoid; }
|
||||
th:nth-child(1), th:nth-child(3) { width: 22%; }
|
||||
th:nth-child(2) { width: 7%; } th:nth-child(4) { width: 8%; }
|
||||
th:nth-child(5) { width: 9%; } th:nth-child(6) { width: 7%; }
|
||||
th:nth-child(7), th:nth-child(8) { width: 5%; } th:nth-child(9) { width: 7%; } th:nth-child(10) { width: 8%; }
|
||||
.money { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
button { padding: 8px 14px; cursor: pointer; }
|
||||
@media print { body { margin: 0; } .toolbar { display: none; } th { print-color-adjust: exact; } }
|
||||
`, doc.head);
|
||||
const toolbar = node("div", null, doc.body); toolbar.className = "toolbar";
|
||||
node("button", "Print / Save PDF", toolbar).addEventListener("click", () => popup.print());
|
||||
node("button", "Close", toolbar).addEventListener("click", () => popup.close());
|
||||
node("h1", "Opportunities Extension", doc.body);
|
||||
node("p", `Generated ${new Date().toLocaleString("en-US")} • ${snapshot.rows.length} opportunities`, doc.body).className = "meta";
|
||||
const filters = node("div", null, doc.body); filters.className = "filters";
|
||||
for (const [label, value] of snapshot.filters) {
|
||||
const field = node("div", null, filters);
|
||||
node("strong", `${label}:`, field); node("span", value || "None", field);
|
||||
}
|
||||
const totals = node("div", null, doc.body); totals.className = "totals";
|
||||
for (const card of snapshot.totals) {
|
||||
const section = node("section", null, totals);
|
||||
node("span", card.label, section); node("strong", card.value, section);
|
||||
if (card.detail) node("small", card.detail, section);
|
||||
}
|
||||
node("h2", "Opportunities", doc.body);
|
||||
const table = node("table", null, doc.body);
|
||||
const header = node("tr", null, node("thead", null, table));
|
||||
for (const column of snapshot.columns) {
|
||||
const th = node("th", column.label, header); th.scope = "col";
|
||||
if (column.key === "revenue") th.className = "money";
|
||||
}
|
||||
const body = node("tbody", null, table);
|
||||
for (const values of snapshot.rows) {
|
||||
const row = node("tr", null, body);
|
||||
values.forEach((value, index) => {
|
||||
const td = node("td", value, row);
|
||||
if (snapshot.columns[index].key === "revenue") td.className = "money";
|
||||
});
|
||||
}
|
||||
if (!snapshot.rows.length) node("p", "No opportunities match the selected filters.", doc.body);
|
||||
popup.focus();
|
||||
// Print a frozen snapshot, without changing the extension's scroll or selected view.
|
||||
environment.setTimeout(() => { if (!popup.closed) popup.print(); }, 150);
|
||||
}
|
||||
return { createButton, print };
|
||||
}
|
||||
@@ -111,10 +111,14 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = selectedOrders.has(value);
|
||||
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.indeterminate = selectedOrders.size > 0 && !allOrders.checked;
|
||||
orderButton.textContent = allOrders.checked ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
|
||||
updateOrders();
|
||||
renderRows();
|
||||
});
|
||||
label.append(checkbox, element("span", null, value || "Not provided"));
|
||||
|
||||
@@ -2,10 +2,27 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug, maskToken, environment }) {
|
||||
const { fetch } = environment;
|
||||
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries";
|
||||
function createPayload(offset) {
|
||||
async function requestSRSearchResources(token) {
|
||||
const searchUrl = `${url}?applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=CRMServiceRequest&language=en&limit=-1&onlyData=true`;
|
||||
const headers = { Accept: "application/json", Authorization: `Bearer ${token}` };
|
||||
const entry = logRequestStart({ name: "requestSRSearchResources", method: "GET", url: searchUrl, headers });
|
||||
try {
|
||||
const response = await fetch(searchUrl, { method: "GET", credentials: "include", headers });
|
||||
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||
if (response.status !== 200) throw new Error(`requestSRSearchResources failed with status ${response.status}.`);
|
||||
const data = await response.json();
|
||||
const number = data?.items?.[0]?.savedSearchNumber;
|
||||
if (!Array.isArray(data?.items) || typeof number !== "string" || !number.trim()) {
|
||||
throw new Error("requestSRSearchResources did not return savedSearchNumber in its first item.");
|
||||
}
|
||||
return number.trim();
|
||||
} catch (error) { logRequestFailure(entry, error.message); throw error; }
|
||||
}
|
||||
function createPayload(offset, savedSearchNumber) {
|
||||
if (typeof savedSearchNumber !== "string" || !savedSearchNumber.trim()) throw new Error("Missing SR savedSearchNumber.");
|
||||
return {
|
||||
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true,
|
||||
entity: "CRMServiceRequest", copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
|
||||
entity: "CRMServiceRequest", copiedFrom: `queries/${savedSearchNumber.trim()}`,
|
||||
q: { op: "$and", criteria: [
|
||||
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
|
||||
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
||||
@@ -19,11 +36,13 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
};
|
||||
}
|
||||
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
|
||||
if (!isActive()) return null;
|
||||
const savedSearchNumber = await requestSRSearchResources(token);
|
||||
const items = [];
|
||||
let offset = 0;
|
||||
let attempt = 1;
|
||||
while (isActive()) {
|
||||
const payload = createPayload(offset);
|
||||
const payload = createPayload(offset, savedSearchNumber);
|
||||
const headers = {
|
||||
Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`,
|
||||
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
|
||||
@@ -48,7 +67,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
throw new Error("CRM service request pagination did not advance.");
|
||||
}
|
||||
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;
|
||||
offset = current + received;
|
||||
attempt = 1;
|
||||
@@ -60,5 +79,5 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return { createPayload, requestCrmServiceRequest };
|
||||
return { createPayload, requestCrmServiceRequest, requestSRSearchResources };
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@ function createFusionOpportunities({
|
||||
const payload = createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber);
|
||||
const requestBody = JSON.stringify(payload);
|
||||
// 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({
|
||||
name: "requestOpportunities",
|
||||
method: "POST",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||
function createMessageTypes() {
|
||||
return Object.freeze({
|
||||
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
|
||||
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
|
||||
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
|
||||
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
||||
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||
|
||||
@@ -61,11 +61,13 @@ test("activity badge filters precede Status, are accessible and combine without
|
||||
h.sr.set("B", { state: "missing" });
|
||||
h.actions.set("B", { state: "matched" });
|
||||
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("B"), 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");
|
||||
h.actions.set("A", { state: "matched" });
|
||||
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", () => {
|
||||
const h = setup();
|
||||
h.click("sr", "matched");
|
||||
h.click("actions", "matched");
|
||||
h.click("sr", "missing");
|
||||
h.click("actions", "missing");
|
||||
for (const state of ["idle", "loading", "error", "matched"]) {
|
||||
h.sr.set("A", { state });
|
||||
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.sr.set("A", { state: "matched" });
|
||||
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);
|
||||
assert.deepEqual(results(), ["A"]);
|
||||
h.app.opportunitiesFilterState.tableSearch = "Test B";
|
||||
|
||||
@@ -4,7 +4,8 @@ const { loadApplication } = require("./support/application.cjs");
|
||||
const { response } = require("./support/characterization.cjs");
|
||||
|
||||
function setup(fetch) {
|
||||
const app = loadApplication("content", { fetch });
|
||||
const app = loadApplication("content", { fetch: (url, options) => options.method === "GET"
|
||||
? response({ items: [{ savedSearchNumber: "dynamic-sr-search" }] }) : fetch(url, options) });
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||
app.debugContentView.renderDebugPanel = () => {};
|
||||
|
||||
@@ -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", () => {
|
||||
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
|
||||
.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 file of files) {
|
||||
assert.doesNotThrow(() => new vm.Script(read(file)));
|
||||
|
||||
47
tests/bookmarkHint.test.cjs
Normal file
47
tests/bookmarkHint.test.cjs
Normal 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);
|
||||
});
|
||||
64
tests/bookmarkShortcut.test.cjs
Normal file
64
tests/bookmarkShortcut.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -40,9 +40,12 @@ test("distribution contracts: manifests, execution boundaries and deterministic
|
||||
const base = {
|
||||
manifest_version: 3, name: "Opportunities Extension",
|
||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
version: "1.0.8", permissions: ["cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
|
||||
version: "1.1.0", permissions: ["bookmarks", "cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
|
||||
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"],
|
||||
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*"],
|
||||
js: ["content.js"], all_frames: true, run_at: "document_idle"
|
||||
}, {
|
||||
|
||||
@@ -2,10 +2,10 @@ const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
const response = data => new Response(JSON.stringify(data), { status: 200 });
|
||||
function setup(fetch) {
|
||||
function setup(fetch, searchFetch = async () => response({ items: [{ savedSearchNumber: "dynamic-sr-search" }] })) {
|
||||
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
|
||||
document.createElementNS = (_, tag) => document.createElement(tag);
|
||||
const app = loadApplication("content", { fetch, document, Intl, Date });
|
||||
const app = loadApplication("content", { fetch: (url, options) => options.method === "GET" ? searchFetch(url, options) : fetch(url, options), document, Intl, Date });
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
app.debugContentView.renderDebugPanel = () => {};
|
||||
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||
@@ -15,6 +15,42 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
|
||||
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
|
||||
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
|
||||
|
||||
test("SR saved search runs once before pages and uses the first item, not the default", async () => {
|
||||
const calls = [];
|
||||
const app = setup(async (_url, options) => {
|
||||
const payload = JSON.parse(options.body); calls.push(payload);
|
||||
assert.equal(payload.copiedFrom, "queries/first-search");
|
||||
return response({ items: [sr("A")], count: 1, offset: payload.offset, hasMore: payload.offset === 0 });
|
||||
}, async (url, options) => {
|
||||
assert.equal(options.method, "GET");
|
||||
assert.equal(options.headers.Authorization, "Bearer synthetic");
|
||||
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries?applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=CRMServiceRequest&language=en&limit=-1&onlyData=true");
|
||||
calls.push("lookup");
|
||||
return response({ items: [{ savedSearchNumber: "first-search" }, { savedSearchNumber: "other", isDefault: true }] });
|
||||
});
|
||||
await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1);
|
||||
assert.equal(calls.length, 3); assert.equal(calls[0], "lookup");
|
||||
assert.equal(app.debugContentView.getRequestCategory(app.debugState.requestLog[0]), "serviceRequests");
|
||||
});
|
||||
|
||||
test("failed or empty SR search blocks POST; superseded search never starts pagination", async () => {
|
||||
for (const lookup of [
|
||||
async () => new Response("Denied", { status: 401 }),
|
||||
async () => response({ items: [] }),
|
||||
async () => response({ items: [{ savedSearchNumber: " " }] }),
|
||||
async () => new Response("<html>Login</html>")
|
||||
]) {
|
||||
const app = setup(() => assert.fail("No POST without a saved search"), lookup);
|
||||
await assert.rejects(app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1));
|
||||
assert.equal(app.debugState.requestLog[0].status, "failed");
|
||||
}
|
||||
const app = setup(() => assert.fail("Stale POST"), async () => {
|
||||
app.opportunitiesQueryState.periodRequestVersion = 2;
|
||||
return response({ items: [{ savedSearchNumber: "first" }] });
|
||||
});
|
||||
assert.equal(await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1), null);
|
||||
});
|
||||
|
||||
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
|
||||
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/A"], [[4, null], "N/A"], [[""], "N/A"], [["invalid"], "N/A"]]) {
|
||||
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
|
||||
@@ -79,7 +115,7 @@ test("CRM query preserves payload and pagination after opportunities, uses token
|
||||
assert.equal(options.headers.Preference, "transient");
|
||||
assert.deepEqual(payload, {
|
||||
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "CRMServiceRequest",
|
||||
copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
|
||||
copiedFrom: "queries/dynamic-sr-search",
|
||||
q: { op: "$and", criteria: [
|
||||
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
|
||||
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
||||
@@ -90,8 +126,9 @@ test("CRM query preserves payload and pagination after opportunities, uses token
|
||||
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"],
|
||||
skipInValidFields: true, limit: 15, offset: 0
|
||||
});
|
||||
assert.equal(app.debugState.requestLog.length, 2);
|
||||
for (const log of app.debugState.requestLog) {
|
||||
assert.equal(app.debugState.requestLog.length, 3);
|
||||
assert.equal(app.debugState.requestLog[0].name, "requestSRSearchResources");
|
||||
for (const log of app.debugState.requestLog.slice(1)) {
|
||||
assert.equal(log.name, "resquestCrmServiceRequest");
|
||||
assert.equal(log.requestHeaders.Origin, "https://eeho.fa.us2.oraclecloud.com");
|
||||
assert.equal(log.requestHeaders.Preference, "transient");
|
||||
@@ -144,6 +181,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
|
||||
let resolveOld;
|
||||
const app = setup(() => new Promise(resolve => { resolveOld = resolve; }));
|
||||
const old = app.opportunityCrmServiceRequests.load("token", 1);
|
||||
while (!resolveOld) await new Promise(resolve => setImmediate(resolve));
|
||||
app.opportunitiesQueryState.periodRequestVersion = 2;
|
||||
app.opportunityCrmServiceRequests.reset();
|
||||
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [sr("NEW")];
|
||||
@@ -152,7 +190,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
|
||||
await old;
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
|
||||
assert.equal(app.debugState.requestLog.length, 1);
|
||||
assert.equal(app.debugState.requestLog.length, 2);
|
||||
});
|
||||
|
||||
test("Reference changes share pending pagination and reuse completed CRM results", async () => {
|
||||
@@ -167,6 +205,7 @@ test("Reference changes share pending pagination and reuse completed CRM results
|
||||
app.opportunitiesQueryState.periodRequestVersion = 2;
|
||||
const second = app.opportunityCrmServiceRequests.load("token", 2);
|
||||
assert.equal(first, second);
|
||||
while (!finish) await new Promise(resolve => setImmediate(resolve));
|
||||
finish(response({ items: [sr("A")], count: 1, offset: 0, hasMore: true }));
|
||||
await second;
|
||||
app.opportunitiesQueryState.periodRequestVersion = 3;
|
||||
|
||||
@@ -250,44 +250,6 @@ test("Primary pillar lookup uses Session, orders enabled name/code options and r
|
||||
assert.equal((await loadBackground({ fetch, messageTimeout: 10000 }).dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" })).ok, false);
|
||||
}
|
||||
});
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: 401 is returned to the caller without opening tabs`, async () => {
|
||||
let count = 0;
|
||||
const h = loadBackground({ family, fetch: async () => ++count < 3 ? response({}, 401) : response({ items: [] }) });
|
||||
const result = await h.dispatch({ type, srId: "123" });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.httpStatus, 401);
|
||||
assert.equal(count, 1);
|
||||
assert.equal(h.tabs.length, 0);
|
||||
assert.deepEqual(h.removedTabs, []);
|
||||
assert.deepEqual(result.debugRequests.map(entry => entry.httpStatus), [401]);
|
||||
});
|
||||
}
|
||||
test("concurrent 401 responses do not cause background tab recovery", async () => {
|
||||
let calls = 0;
|
||||
const h = loadBackground({ fetch: async url => {
|
||||
if (++calls <= 2) return response({}, 401);
|
||||
const id = new URL(url).searchParams.get("q");
|
||||
return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: [{ Title: id }] }] });
|
||||
} });
|
||||
const results = await Promise.all([h.dispatch({ type, srId: "1" }), h.dispatch({ type, srId: "2" })]);
|
||||
assert.equal(h.tabs.length, 0);
|
||||
assert.deepEqual(h.removedTabs, []);
|
||||
assert.ok(results.every(result => result.httpStatus === 401));
|
||||
});
|
||||
test("background never creates recovery tabs after an error", async () => {
|
||||
for (const status of [401, 500]) {
|
||||
let count = 0;
|
||||
const h = loadBackground({ fetch: async () => response({}, ++count === 1 ? 401 : status) });
|
||||
const result = await h.dispatch({ type, srId: "1" });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(count, 1);
|
||||
assert.deepEqual(h.removedTabs, []);
|
||||
}
|
||||
const h = loadBackground({ fetch: async () => response({}, 403) });
|
||||
assert.equal((await h.dispatch({ type, srId: "1" })).ok, false);
|
||||
assert.equal(h.tabs.length, 0);
|
||||
});
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
|
||||
const calls = [];
|
||||
|
||||
@@ -84,7 +84,7 @@ test("internal SR form validates Save, maps parent data, prevents duplicate post
|
||||
assert.ok(!h.walk(h.modal).includes(form));
|
||||
});
|
||||
|
||||
test("internal SR uses Session without TokenRelay and reports unavailable iframe host", async () => {
|
||||
test("internal SR uses Session and offers manual retry after 401 without automatic probes", async () => {
|
||||
const h = setup();
|
||||
let renewals = 0;
|
||||
const sent = [];
|
||||
@@ -106,6 +106,11 @@ test("internal SR uses Session without TokenRelay and reports unavailable iframe
|
||||
assert.equal(sent[0].token, undefined);
|
||||
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
|
||||
assert.ok(h.walk(h.panel()).some(node => node.attributes.role === "alert"));
|
||||
const retry = h.walk(h.panel()).find(node => node.attributes["aria-label"] === "Retry loading Internal Service Requests");
|
||||
assert.ok(retry);
|
||||
retry.listeners.click();
|
||||
await flush();
|
||||
assert.equal(sent.length, 2);
|
||||
});
|
||||
|
||||
function setup() {
|
||||
|
||||
@@ -194,7 +194,7 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
|
||||
assert.equal(same, app.currentUser.ensure());
|
||||
finish({ ok: true, user: { ...validIdentity.user, roles } });
|
||||
await first;
|
||||
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]);
|
||||
assert.deepEqual(calls, [type, "tokenrelay", "opportunities", ...(allowed ? ["crm"] : [])]);
|
||||
app.productsContentState.currentProductsRequestId = "products-cycle";
|
||||
app.productActivities.begin("products-cycle", "synthetic");
|
||||
const products = { items: [{ RevnId: "123" }] };
|
||||
|
||||
76
tests/dialogDrag.test.cjs
Normal file
76
tests/dialogDrag.test.cjs
Normal 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);
|
||||
}
|
||||
});
|
||||
14
tests/internalSrRecovery.test.cjs
Normal file
14
tests/internalSrRecovery.test.cjs
Normal file
@@ -0,0 +1,14 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const factory = vm.runInNewContext(fs.readFileSync('src/services/fusion/sr-details.js', 'utf8') + '\ncreateFusionSrDetails');
|
||||
test('Internal SR background returns 401 without opening any tab or retrying', async () => {
|
||||
let calls = 0;
|
||||
const api = factory({
|
||||
fetchJson: async () => { calls++; throw Object.assign(new Error('Unauthorized'), {httpStatus:401}); },
|
||||
tabsCreate: () => assert.fail('Internal SR must not open a tab')
|
||||
});
|
||||
await assert.rejects(api.requestInternalServiceRequest({srId:'123'}), /Unauthorized/);
|
||||
assert.equal(calls,1);
|
||||
});
|
||||
@@ -1,145 +1,69 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const vm = require("node:vm");
|
||||
const path = require("node:path");
|
||||
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/internal-sr-session.js"), "utf8")}\ncreateInternalSrSession`);
|
||||
function setup(query, options = {}) {
|
||||
let elapsed = 0;
|
||||
const frames = [], removed = [], delays = [], stopped = [];
|
||||
const body = { append(frame) { frame.initialSrc = frame.src; frames.push(frame); } };
|
||||
const document = { body, createElement(tag) {
|
||||
assert.equal(tag, "iframe");
|
||||
return { contentWindow: { stop() { stopped.push(true); } }, style: {}, attributes: {}, listeners: {}, setAttribute(k, v) { this.attributes[k] = v; },
|
||||
addEventListener(k, fn) { this.listeners[k] = fn; }, remove() { removed.push(this); } };
|
||||
} };
|
||||
const api = factory({ ...options, query, environment: { document, performance: { now: () => elapsed }, setTimeout: (fn, ms) => { delays.push(ms); elapsed += ms; return setImmediate(fn); } } });
|
||||
return { api, frames, removed, delays, stopped };
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const factory = vm.runInNewContext(fs.readFileSync('src/features/opportunities/internal-sr-session.js', 'utf8') + '\ncreateInternalSrSession');
|
||||
test('without a document, session recovery stops without polling', async () => {
|
||||
for (const result of [{ok:false,httpStatus:401}, {ok:false,debugRequests:[{httpStatus:401}]}, {ok:false,error:'Request failed with status 401.'}]) {
|
||||
let calls=0;
|
||||
const service=factory({query:async()=>{calls++; return result;},environment:{document:{createElement(){assert.fail('No iframe');}},setTimeout(){assert.fail('No polling');}}});
|
||||
await assert.rejects(service.request('123'), /Internal SR|status 401/);
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(calls,1);
|
||||
}
|
||||
});
|
||||
|
||||
function harness() {
|
||||
const timers = [], listeners = new Map();
|
||||
let appended = 0, removed = 0;
|
||||
const frame = {style:{},setAttribute(){},addEventListener(k,v){listeners.set(k,v);},removeEventListener(k){listeners.delete(k);},remove(){removed++;}};
|
||||
return { timers, frame, listeners, counts:()=>({appended,removed}), environment:{
|
||||
document:{createElement:()=>frame,body:{append(){appended++;}}},
|
||||
setTimeout(fn,delay){timers.push({fn,delay});}
|
||||
}};
|
||||
}
|
||||
|
||||
test("lookup requests recover together using the configured page and preserve each result", async () => {
|
||||
let authenticated = false;
|
||||
let calls = 0;
|
||||
const h = setup(async message => {
|
||||
if (++calls <= 4) return { ok: false, httpStatus: 401 };
|
||||
authenticated = true;
|
||||
return { ok: true, items: [message.type] };
|
||||
}, { sessionUrl: "https://eeho.fa.us2.oraclecloud.com/?view=Show%20Service%20Request", sessionLabel: "Service Request" });
|
||||
const types = ["pillar", "language", "platform", "services"];
|
||||
const results = await Promise.all(types.map(type => h.api.request({ type })));
|
||||
assert.deepEqual(results, types.map(type => [type]));
|
||||
assert.equal(authenticated, true);
|
||||
assert.equal(h.frames.length, 1);
|
||||
assert.match(h.frames[0].initialSrc, /Show%20Service%20Request$/);
|
||||
assert.equal(h.frames[0].title, "Service Request session initialization");
|
||||
assert.equal(h.removed.length, 1);
|
||||
assert.equal(h.stopped.length, 1);
|
||||
const flush = () => new Promise(resolve=>setImmediate(resolve));
|
||||
test('hidden iframe remains alive after load and closes only after successful session query',async()=>{
|
||||
const h=harness(); let calls=0;
|
||||
const api=factory({environment:h.environment,query:async()=>++calls<3?{ok:false,httpStatus:401}:{ok:true,items:[{SrId:1}]}});
|
||||
const first=api.request('123'); assert.equal(first,api.request(123));
|
||||
await flush();
|
||||
assert.equal(h.counts().appended,1);
|
||||
h.listeners.get('load')?.();
|
||||
assert.equal(h.counts().removed,0);
|
||||
assert.equal(calls,1);
|
||||
assert.equal(h.timers[0].delay,2000);
|
||||
h.timers.shift().fn(); await flush();
|
||||
assert.equal(h.counts().removed,0);
|
||||
assert.equal(h.timers[0].delay,4000);
|
||||
h.timers.shift().fn();
|
||||
assert.equal((await first)[0].SrId,1);
|
||||
assert.equal(h.counts().removed,1);
|
||||
});
|
||||
test("401 initializes a hidden iframe in the calling document and removes it on success", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async () => ++calls < 3 ? { ok: false, httpStatus: 401 } : { ok: true, items: [{ SrId: 1 }] });
|
||||
const items = await h.api.request("1");
|
||||
assert.equal(items[0].SrId, 1);
|
||||
assert.equal(h.frames.length, 1);
|
||||
const frame = h.frames[0];
|
||||
assert.equal(frame.tabIndex, -1);
|
||||
assert.equal(frame.attributes["aria-hidden"], "true");
|
||||
assert.match(frame.style.cssText, /pointer-events:none/);
|
||||
assert.ok(!frame.attributes.sandbox.includes("allow-top-navigation"));
|
||||
assert.ok(frame.initialSrc.endsWith("&view=showisr"));
|
||||
assert.equal(frame.src, "about:blank");
|
||||
assert.equal(h.stopped.length, 1);
|
||||
assert.deepEqual(h.delays, [250]);
|
||||
assert.equal(h.removed[0], frame);
|
||||
});
|
||||
|
||||
test("an immediately available session stops the iframe without waiting for load or a timer", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async () => ++calls === 1 ? { ok: false, httpStatus: 401 } : { ok: true, items: [] });
|
||||
await h.api.request("1");
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(h.delays.length, 0);
|
||||
assert.equal(h.stopped.length, 1);
|
||||
assert.equal(h.removed.length, 1);
|
||||
});
|
||||
|
||||
test("lookup 401 errors from older background responses still initialize the session", async () => {
|
||||
for (const failure of [
|
||||
{ ok: false, error: "requestSrLookupLanguage failed with status 401." },
|
||||
{ ok: false, debugRequests: [{ httpStatus: 401 }] },
|
||||
{ ok: false, httpStatus: "401" }
|
||||
]) {
|
||||
let calls = 0;
|
||||
const h = setup(async () => ++calls <= 2 ? failure : { ok: true, items: ["ready"] });
|
||||
assert.deepEqual(await h.api.request("language"), ["ready"]);
|
||||
assert.equal(h.frames.length, 1);
|
||||
assert.equal(h.removed.length, 1);
|
||||
test('persistent 401 is bounded to five recovery checks and removes iframe',async()=>{
|
||||
const h=harness(); let calls=0;
|
||||
const api=factory({environment:h.environment,query:async()=>{calls++;return {ok:false,httpStatus:401};}});
|
||||
const failed=assert.rejects(api.request('123'),/still unavailable/);
|
||||
for(const delay of [2000,4000,8000,12000,16000]) {
|
||||
await flush(); assert.equal(h.timers[0].delay,delay); h.timers.shift().fn();
|
||||
}
|
||||
await failed;
|
||||
assert.equal(calls,6);
|
||||
assert.equal(h.counts().removed,1);
|
||||
assert.equal(h.timers.length,0);
|
||||
});
|
||||
|
||||
test("a lookup that still returns 401 after shared recovery initializes its own session", async () => {
|
||||
const calls = new Map();
|
||||
const h = setup(async id => {
|
||||
const count = (calls.get(id) || 0) + 1;
|
||||
calls.set(id, count);
|
||||
return count <= (id === "language" ? 1 : 2)
|
||||
? { ok: false, httpStatus: 401 } : { ok: true, items: [id] };
|
||||
});
|
||||
assert.deepEqual(await Promise.all([h.api.request("language"), h.api.request("platform")]), [["language"], ["platform"]]);
|
||||
assert.equal(h.frames.length, 2);
|
||||
assert.equal(h.removed.length, 2);
|
||||
test('concurrent requests share work and explicit retry can recover', async () => {
|
||||
let calls=0, complete;
|
||||
const service=factory({query:()=>{calls++; return new Promise(resolve=>{complete=resolve;});}});
|
||||
const first=service.request('123'); const second=service.request(123);
|
||||
assert.equal(first,second); await Promise.resolve();
|
||||
complete({ok:false,httpStatus:401}); await assert.rejects(first);
|
||||
const retry=service.request('123'); await Promise.resolve(); complete({ok:true,items:[{SrId:'456'}]});
|
||||
assert.equal((await retry)[0].SrId,'456'); assert.equal(calls,2);
|
||||
});
|
||||
|
||||
test("cross-origin redirects still allow iframe removal when stop is blocked", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async () => {
|
||||
if (++calls === 1) return { ok: false, httpStatus: 401 };
|
||||
h.frames[0].contentWindow.stop = () => { throw new Error("Cross-origin"); };
|
||||
return { ok: true, items: [] };
|
||||
});
|
||||
await h.api.request("1");
|
||||
assert.equal(h.frames[0].src, "about:blank");
|
||||
assert.equal(h.removed.length, 1);
|
||||
});
|
||||
test("concurrent queries share one iframe without mixing SR results", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async id => ++calls <= 2 ? { ok: false, httpStatus: 401 } : { ok: true, items: [id] });
|
||||
const results = await Promise.all([h.api.request("1"), h.api.request("2")]);
|
||||
assert.deepEqual(results, [["1"], ["2"]]);
|
||||
assert.equal(h.frames.length, 1);
|
||||
assert.equal(h.removed.length, 1);
|
||||
});
|
||||
test("normal success does not mount an iframe; errors and timeout always clean it up", async () => {
|
||||
const success = setup(async () => ({ ok: true, items: [] }));
|
||||
await success.api.request("1");
|
||||
assert.equal(success.frames.length, 0);
|
||||
for (const status of [401, 500]) {
|
||||
let calls = 0;
|
||||
const h = setup(async () => ({ ok: false, httpStatus: ++calls === 1 ? 401 : status }));
|
||||
await assert.rejects(h.api.request("1"));
|
||||
if (status === 401) {
|
||||
assert.ok(calls > 31);
|
||||
assert.equal(h.delays.reduce((sum, ms) => sum + ms, 0), 60000);
|
||||
} else assert.equal(calls, 2);
|
||||
assert.equal(h.removed.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("slow session initialization can succeed after the old thirty-attempt limit", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async () => ++calls < 45 ? { ok: false, httpStatus: 401 } : { ok: true, items: ["ready"] });
|
||||
assert.deepEqual(await h.api.request("1"), ["ready"]);
|
||||
assert.equal(calls, 45);
|
||||
assert.equal(h.removed.length, 1);
|
||||
assert.ok(h.delays.reduce((sum, ms) => sum + ms, 0) < 60000);
|
||||
});
|
||||
|
||||
test("iframe load alone never confirms authentication", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async () => {
|
||||
if (++calls > 1) h.frames[0].listeners.load();
|
||||
return { ok: false, httpStatus: 401 };
|
||||
});
|
||||
await assert.rejects(h.api.request("1"), /HTTP 401.*load event, but authentication was not confirmed/);
|
||||
assert.equal(h.removed.length, 1);
|
||||
test('other errors do not retry and successful empty results are preserved', async()=>{
|
||||
const failed=factory({query:async()=>({ok:false,error:'Forbidden',httpStatus:403})});
|
||||
await assert.rejects(failed.request('123'),/Forbidden/);
|
||||
const ok=factory({query:async()=>({ok:true,items:[]})}); assert.equal((await ok.request('123')).length,0);
|
||||
});
|
||||
|
||||
41
tests/modalShortcuts.test.cjs
Normal file
41
tests/modalShortcuts.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -9,7 +9,7 @@ const plain = value => JSON.parse(JSON.stringify(value));
|
||||
const opportunity = (number, date) => ({ OptyNumber: number, Name: `<Opportunity ${number}>`, EffectiveDate: date,
|
||||
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;
|
||||
class Element {
|
||||
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"),
|
||||
createViewIcon: view => { const icon = new Element("svg"); icon.view = view; return icon; },
|
||||
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);
|
||||
const walk = element => [element, ...element.children.flatMap(walk)];
|
||||
h.nodes = () => walk(host);
|
||||
@@ -54,6 +54,23 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
|
||||
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", () => {
|
||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", null)]);
|
||||
const { options } = h.activate();
|
||||
|
||||
29
tests/shortcutLink.test.cjs
Normal file
29
tests/shortcutLink.test.cjs
Normal 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);
|
||||
});
|
||||
11
tests/srRequestPriority.test.cjs
Normal file
11
tests/srRequestPriority.test.cjs
Normal 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']);
|
||||
});
|
||||
Reference in New Issue
Block a user