Added the ability to bookmark the extension, along with some visual adjustments

This commit is contained in:
2026-10-30 14:48:00 -03:00
parent 00bcfdc42e
commit 9cdb46b6c2
39 changed files with 1566 additions and 137 deletions

View File

@@ -316,15 +316,15 @@ scrolling is reserved for table containers below
1120px. The scoped table stylesheet does not change product-table column widths
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,
@@ -393,3 +393,5 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.
With the Opportunities Extension modal open, press Ctrl+Shift+F to choose a browser bookmarks folder and save the extension shortcut.

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,7 @@
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.9",
"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
View File

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

View File

@@ -292,6 +292,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
/** Stable wire names. All payloads remain plain, serializable objects. */
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 }));

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,7 @@
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.9",
"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
View File

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

View File

@@ -9,6 +9,7 @@ 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 = {
@@ -17,6 +18,7 @@ const baseManifest = {
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.9",
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);
}

View File

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

View File

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

View File

@@ -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(); },
@@ -273,7 +285,6 @@ function createContentApplication(overrides = {}) {
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
});
const fusionOpportunities = createFusionOpportunities({
loadServiceRequests: (token, version) => opportunityCrmServiceRequests.load(token, version),
resetDebugRequests: () => debugState.reset(),
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
@@ -507,6 +518,7 @@ function createContentApplication(overrides = {}) {
});
const opportunitiesLinks = createOpportunitiesLinks({
MODAL_ID: contracts.MODAL_ID,
searchableSelect,
environment
});
const preferencesStorage = createPreferencesStorage({

View File

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

View File

@@ -9,6 +9,20 @@ function createContentFusionPage({
environment
}) {
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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,10 +218,16 @@ 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());

View File

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

View File

@@ -28,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;
@@ -44,15 +60,6 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
}
byOpportunity = grouped;
loadedItems = items;
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
// Refresh SR badges, filters and totals independently of product/action jobs.
onStateChange();
return true;
} catch {
if (isActive()) { status = "error"; reportProgress("error"); onStateChange(); }
return false;
}
}
function getSummary(optyNumber) {
if (!allowsCrmActivities() || !optyNumber) return null;
@@ -61,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)}`,
@@ -87,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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ function createFusionOpportunities({
getOpportunityTypeValues,
readResponseBodyForDebug,
resetDebugRequests = () => {},
loadServiceRequests = async () => {},
environment
}) {
const { fetch } = environment;
@@ -40,9 +39,6 @@ function createFusionOpportunities({
// One history per query, preserving all pages of the same result set.
if (page === 1) {
resetDebugRequests();
// SR queries do not depend on opportunity or product IDs. Keep their
// diagnostics in this query's history and reuse the loaded SR state later.
try { await loadServiceRequests(token, requestVersion); } catch { /* SR failure must not block opportunities. */ }
if (!isCurrentPeriodRequest(requestVersion)) return null;
}
const requestEntry = logRequestStart({

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,9 +40,12 @@ test("distribution contracts: manifests, execution boundaries and deterministic
const base = {
manifest_version: 3, name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.9", permissions: ["cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
version: "1.0.9", 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"
}, {

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

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

View File

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

View File

@@ -9,7 +9,7 @@ const plain = value => JSON.parse(JSON.stringify(value));
const opportunity = (number, date) => ({ OptyNumber: number, Name: `<Opportunity ${number}>`, EffectiveDate: date,
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();

View File

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

View File

@@ -1,8 +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/services/fusion/opportunities.js','utf8')+'\ncreateFusionOpportunities');
for(const mode of ['success','failure','stale'])test(`SR precedes opportunities: ${mode}`,async()=>{
const order=[];let active=true;let page=0;
const api=factory({OPPORTUNITIES_PAGE_LIMIT:1,OPPORTUNITIES_QUERY_URL:'https://example.invalid',isCurrentPeriodRequest:()=>active,getOpportunityTypeValues:()=>new Set(['ALL']),logRequestStart:()=>({}),logRequestSuccess(){},logRequestFailure(){},readResponseBodyForDebug:async()=>'',resetDebugRequests:()=>order.push('reset'),loadServiceRequests:async()=>{order.push('sr');if(mode==='stale')active=false;if(mode==='failure')throw Error('SR failed');},environment:{fetch:async()=>{order.push('opportunities');page++;return {ok:true,json:async()=>({items:[{OptyNumber:String(page)}],hasMore:page===1,count:1,offset:page-1})};}}});
await api.requestOpportunitiesPage('token','CQ',{startCloseDate:'2026-09-01',endCloseDate:'2026-12-01'},0,[],1,1,'saved-search');
assert.deepEqual(order,mode==='stale'?['reset','sr']:['reset','sr','opportunities','opportunities']);
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']);
});